using System.CommandLine; using System.CommandLine.Parsing; namespace ScadaLink.CLI.Commands; /// /// The scadalink audit command group (Audit Log #23 M8). Provides read access to /// the centralized append-only Audit Log via the Bundle B REST endpoints /// (GET /api/audit/query, GET /api/audit/export), plus a v1 no-op /// verify-chain placeholder for the deferred hash-chain tamper-evidence feature. /// public static class AuditCommands { 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(BuildVerifyChain(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 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; } }