using System.CommandLine; using System.CommandLine.Parsing; 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. /// 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(BuildBackfillSourceNode(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). AcceptOnlyFromAmong validates EACH supplied value. var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable", AllowMultipleArgumentsPerToken = true, }; channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable", AllowMultipleArgumentsPerToken = true, }; kindOption.AcceptOnlyFromAmong( "ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve"); var statusOption = new Option("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable", AllowMultipleArgumentsPerToken = true, }; statusOption.AcceptOnlyFromAmong( "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped"); 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 }; var formatExportOption = new Option("--format") { Description = "Export format", Required = true }; formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet"); 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). AcceptOnlyFromAmong validates EACH supplied value. var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable", AllowMultipleArgumentsPerToken = true, }; channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound"); var kindOption = new Option("--kind") { Description = "Filter by event kind (ApiCall, ApiCallCached, DbWrite, DbWriteCached, NotifySend, NotifyDeliver, InboundRequest, InboundAuthFailure, CachedSubmit, CachedResolve); repeatable", AllowMultipleArgumentsPerToken = true, }; kindOption.AcceptOnlyFromAmong( "ApiCall", "ApiCallCached", "DbWrite", "DbWriteCached", "NotifySend", "NotifyDeliver", "InboundRequest", "InboundAuthFailure", "CachedSubmit", "CachedResolve"); var statusOption = new Option("--status") { Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped); repeatable", AllowMultipleArgumentsPerToken = true, }; statusOption.AcceptOnlyFromAmong( "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped"); 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), }; try { using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!); return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow); } catch (FormatException ex) { OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT"); return 1; } }); 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 = "Month to verify (YYYY-MM)", Required = true }; var cmd = new Command("verify-chain") { Description = "Verify the audit log hash chain for a month" }; cmd.Add(monthOption); cmd.SetAction((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; } 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; }); return cmd; } /// /// 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; } }