using System.CommandLine; using System.CommandLine.Parsing; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; 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 verify-chain, which sends the /// VerifyAuditChain management command (hash-chain tamper evidence, ScadaBridge#41), /// verify-heads, which checks off-box exported chain heads with /// VerifyAuditChainHeads (ScadaBridge#107), and archive list / archive verify /// / archive cleanup, which send ListAuditArchives / VerifyAuditArchive /// (Parquet cold archive, ScadaBridge#42) / CleanupAuditArchives (ScadaBridge#109). /// public static class AuditCommands { /// /// Builds the audit command group with query, export, tree, and verify-chain /// sub-commands. /// /// Global --url option for the management API endpoint. /// Global --format option for output format. /// Global --username option for authentication. /// Global --password option for authentication. /// The configured audit with all sub-commands attached. public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var command = new Command("audit") { Description = "Query and export the centralized audit log" }; 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)); command.Add(BuildVerifyHeads(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildBackfillSourceNode(urlOption, formatOption, usernameOption, passwordOption)); command.Add(BuildArchive(urlOption, formatOption, usernameOption, passwordOption)); return command; } private static Command BuildQuery(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" }; var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" }; // --channel/--kind/--status/--site are multi-valued: System.CommandLine accepts // both repeated tokens (--channel A --channel B) and, with // AllowMultipleArgumentsPerToken, a single token carrying several values // (--channel A B). // // The valid sets are named in the help text rather than enforced with // AcceptOnlyFromAmong: that check is case-SENSITIVE, so `--status delivered` // failed client-side even though the server has always parsed these // case-insensitively (register row 99 — same treatment row 88 gave // `deploy status`). The server rejects an unrecognised value with a 400 naming // the same list and is the authoritative check; HTTP API callers never pass // through these options at all. var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var statusOption = new Option("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var siteOption = new Option("--site") { Description = "Filter by source site ID; repeatable", AllowMultipleArgumentsPerToken = true, }; var targetOption = new Option("--target") { Description = "Filter by target (external system, DB connection, notification list)" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; var correlationIdOption = new Option("--correlation-id") { Description = "Filter by correlation ID" }; var executionIdOption = new Option("--execution-id") { Description = "Filter by execution ID" }; var parentExecutionIdOption = new Option("--parent-execution-id") { Description = "Filter by parent execution ID" }; var errorsOnlyOption = new Option("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" }; var pageSizeOption = new Option("--page-size") { Description = "Events per page (1-1000)" }; pageSizeOption.DefaultValueFactory = _ => 100; var allOption = new Option("--all") { Description = "Fetch every page, following the keyset cursor" }; var cmd = new Command("query") { Description = "Query audit log events" }; cmd.Add(sinceOption); cmd.Add(untilOption); cmd.Add(channelOption); cmd.Add(kindOption); cmd.Add(statusOption); cmd.Add(siteOption); cmd.Add(targetOption); cmd.Add(actorOption); cmd.Add(correlationIdOption); cmd.Add(executionIdOption); cmd.Add(parentExecutionIdOption); cmd.Add(errorsOnlyOption); cmd.Add(pageSizeOption); cmd.Add(allOption); 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 format = AuditCommandHelpers.ResolveFormat(result, formatOption); var formatter = AuditFormatterFactory.Create(format, Console.Error); var args = new AuditQueryArgs { Since = result.GetValue(sinceOption), Until = result.GetValue(untilOption), Channel = result.GetValue(channelOption) ?? Array.Empty(), Kind = result.GetValue(kindOption) ?? Array.Empty(), Status = result.GetValue(statusOption) ?? Array.Empty(), Site = result.GetValue(siteOption) ?? Array.Empty(), Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), CorrelationId = result.GetValue(correlationIdOption), ExecutionId = result.GetValue(executionIdOption), ParentExecutionId = result.GetValue(parentExecutionIdOption), ErrorsOnly = result.GetValue(errorsOnlyOption), PageSize = result.GetValue(pageSizeOption), }; var fetchAll = result.GetValue(allOption); try { using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); return await AuditQueryHelpers.RunQueryAsync( client, args, fetchAll, formatter, Console.Out, DateTimeOffset.UtcNow); } catch (FormatException ex) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } }); return cmd; } private static Command BuildExport(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var sinceOption = new Option("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; var untilOption = new Option("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true }; // Same rationale as --channel/--kind/--status below and on `query` (register // row 99): AcceptOnlyFromAmong is case-SENSITIVE, so it rejected `--format CSV` // even though the server lowercases the value before matching. The server // returns a 400 naming csv/jsonl/parquet and is the authoritative check. var formatExportOption = new Option("--format") { Description = "Export format (csv, jsonl, parquet; case-insensitive)", Required = true, }; var outputOption = new Option("--output") { Description = "Destination file path", Required = true }; // --channel/--kind/--status/--site are multi-valued — same shape as the // `query` subcommand: repeated tokens (--channel A --channel B) and, with // AllowMultipleArgumentsPerToken, a single token carrying several values // (--channel A B). Valid sets are named in the help text and enforced // server-side, case-insensitively — see the `query` builder for the full note. var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var statusOption = new Option("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable", AllowMultipleArgumentsPerToken = true, }; var siteOption = new Option("--site") { Description = "Filter by source site ID; repeatable", AllowMultipleArgumentsPerToken = true, }; var targetOption = new Option("--target") { Description = "Filter by target" }; var actorOption = new Option("--actor") { Description = "Filter by actor" }; var cmd = new Command("export") { Description = "Export audit log events to a file" }; cmd.Add(sinceOption); cmd.Add(untilOption); cmd.Add(formatExportOption); cmd.Add(outputOption); cmd.Add(channelOption); cmd.Add(kindOption); cmd.Add(statusOption); cmd.Add(siteOption); cmd.Add(targetOption); cmd.Add(actorOption); 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 args = new AuditExportArgs { Since = result.GetValue(sinceOption)!, Until = result.GetValue(untilOption)!, Format = result.GetValue(formatExportOption)!, Output = result.GetValue(outputOption)!, Channel = result.GetValue(channelOption) ?? Array.Empty(), Kind = result.GetValue(kindOption) ?? Array.Empty(), Status = result.GetValue(statusOption) ?? Array.Empty(), Site = result.GetValue(siteOption) ?? Array.Empty(), Target = result.GetValue(targetOption), Actor = result.GetValue(actorOption), }; // Ctrl-C is the one way to abort an in-flight export other than the // headers/inactivity deadlines below — mirrors the CancellationTokenSource // owned by DebugCommands' streaming debug-view command (this was the // one CLI HTTP path with NO deadline at all). using var cts = new CancellationTokenSource(); ConsoleCancelEventHandler onCancel = (_, e) => { e.Cancel = true; cts.Cancel(); }; Console.CancelKeyPress += onCancel; try { using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow, cts.Token); } catch (FormatException ex) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } finally { Console.CancelKeyPress -= onCancel; } }); return cmd; } private static Command BuildTree(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var executionIdOption = new Option("--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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { 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; exit 3 and report the first break if it was tampered with", }; cmd.Add(monthOption); cmd.SetAction(async (ParseResult result) => { var month = result.GetValue(monthOption)!; if (!AuditVerifyChainHelpers.IsValidMonth(month)) { OutputFormatter.WriteError( $"Invalid month '{month}'. Expected YYYY-MM (e.g. 2026-05).", "INVALID_ARGUMENT"); return 1; } 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; } /// /// Builds the audit archive group (ScadaBridge#42): list and verify /// over the AuditLog Parquet cold archives on the central archive directory. /// private static Command BuildVerifyHeads(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var pathOption = new Option("--path") { Description = "Export file, or directory of *.jsonl/*.log/*.txt export files (file sink or syslog collector output)", Required = true, }; var cmd = new Command("verify-heads") { Description = "Check AuditLog chain heads exported off-box against the database chain; exit 3 if any no longer matches", }; cmd.Add(pathOption); cmd.SetAction(async (ParseResult result) => { AuditVerifyHeadsHelpers.ReadResult read; try { read = AuditVerifyHeadsHelpers.ReadRecords(result.GetValue(pathOption)!); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } if (read.Records.Count == 0) { OutputFormatter.WriteError( $"No chain-head records found in {read.Files} file(s) ({read.LinesSkipped} unparsable line(s)).", "INVALID_ARGUMENT"); return 1; } if (read.LinesSkipped > 0) { Console.Error.WriteLine($"warning: skipped {read.LinesSkipped} line(s) without a chain-head record."); } var format = AuditCommandHelpers.ResolveFormat(result, formatOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new VerifyAuditChainHeadsCommand(read.Records), timeout: AuditVerifyHeadsHelpers.RequestTimeout, onSuccess: json => AuditVerifyHeadsHelpers.Render(json, format, Console.Out)); }); return cmd; } private static Command BuildArchive(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var group = new Command("archive") { Description = "List, verify and clean up the AuditLog Parquet cold archives" }; var list = new Command("list") { Description = "List the AuditLog Parquet archives written by the retention purge" }; list.SetAction(async (ParseResult result) => { var format = AuditCommandHelpers.ResolveFormat(result, formatOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new ListAuditArchivesCommand(), onSuccess: json => AuditArchiveHelpers.RenderList(json, format, Console.Out)); }); group.Add(list); var nameOption = new Option("--name") { Description = "Archive name, as 'audit archive list' prints it (auditlog-YYYY-MM-yyyyMMddTHHmmssfffZ)", Required = true, }; var verify = new Command("verify") { Description = "Verify an archive's files, its hash chain and its purge anchor; exit 3 if it does not verify", }; verify.Add(nameOption); verify.SetAction(async (ParseResult result) => { var format = AuditCommandHelpers.ResolveFormat(result, formatOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new VerifyAuditArchiveCommand(result.GetValue(nameOption)!), timeout: AuditArchiveHelpers.VerifyRequestTimeout, onSuccess: json => AuditArchiveHelpers.RenderVerify(json, format, Console.Out)); }); group.Add(verify); var dryRunOption = new Option("--dry-run") { Description = "Report what would be deleted; delete nothing" }; var cleanup = new Command("cleanup") { Description = "Delete superseded stale archives and archives past AuditLog:Archive:RetentionDays (Administrator)", }; cleanup.Add(dryRunOption); cleanup.SetAction(async (ParseResult result) => { var format = AuditCommandHelpers.ResolveFormat(result, formatOption); return await CommandHelpers.ExecuteCommandAsync( result, urlOption, formatOption, usernameOption, passwordOption, new CleanupAuditArchivesCommand(result.GetValue(dryRunOption)), timeout: AuditArchiveHelpers.VerifyRequestTimeout, onSuccess: json => AuditArchiveHelpers.RenderCleanup(json, format, Console.Out)); }); group.Add(cleanup); return group; } /// /// Builds the audit backfill-source-node sub-command. /// Sets SourceNode on historical pre-feature rows whose SourceNode IS NULL /// and OccurredAtUtc is older than --before, in batches. Admin-only. /// private static Command BuildBackfillSourceNode(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var sentinelOption = new Option("--sentinel") { Description = "Value to write for pre-feature rows whose node-of-origin is unknown (default: unknown)", }; sentinelOption.DefaultValueFactory = _ => "unknown"; var beforeOption = new Option("--before") { Description = "ISO-8601 UTC datetime; only rows older than this date are eligible (required)", Required = true, }; var batchOption = new Option("--batch") { Description = "Max rows updated per batch (default: 5000)", }; batchOption.DefaultValueFactory = _ => 5000; var cmd = new Command("backfill-source-node") { Description = "Set SourceNode to a sentinel value on pre-feature rows where it is NULL (admin-only, maintenance path)", }; cmd.Add(sentinelOption); cmd.Add(beforeOption); cmd.Add(batchOption); 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 sentinel = result.GetValue(sentinelOption) ?? "unknown"; var before = result.GetValue(beforeOption)!; var batch = result.GetValue(batchOption); if (string.IsNullOrWhiteSpace(sentinel)) { OutputFormatter.WriteError("--sentinel must be a non-empty string.", "INVALID_ARGUMENT"); return 1; } if (batch <= 0) { OutputFormatter.WriteError("--batch must be > 0.", "INVALID_ARGUMENT"); return 1; } var args = new AuditBackfillSourceNodeArgs { Sentinel = sentinel, Before = before, BatchSize = batch, }; using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); return await AuditBackfillHelpers.RunBackfillAsync(client, args, Console.Out); }); return cmd; } }