diff --git a/docs/plans/2026-05-20-auditlog-m8-cli.md b/docs/plans/2026-05-20-auditlog-m8-cli.md new file mode 100644 index 0000000..3e68f00 --- /dev/null +++ b/docs/plans/2026-05-20-auditlog-m8-cli.md @@ -0,0 +1,21 @@ +# Audit Log #23 — M8 CLI Implementation Plan + +> **For Claude:** subagent-driven-development with bundled cadence. FINAL milestone. + +**Goal:** Operator CLI surface — `scadalink audit query | export | verify-chain` — plus the ManagementService HTTP endpoints they call, output formatters, and renaming the pre-existing `audit-log` config-change command to `audit-config` with a deprecation alias. + +**M7 realities baked in:** +- `OperationalAudit` + `AuditExport` are role-claim policies (M7 Bundle G). The Management endpoints reuse them. +- `IAuditLogRepository.QueryAsync` (keyset paging) + `GetKpiSnapshotAsync` exist. +- `AuditLogQueryFilter` is single-value per dimension — the CLI's `--channel` etc. flags collapse to single values like the UI chips do (documented limitation). +- `verify-chain` is a v1 no-op stub (hash-chain deferred to v1.x per alog.md locked decisions). Do NOT implement hash chains. +- ManagementService surface: confirm controllers vs minimal API by reading the project (M7 found CentralUI uses minimal API; ManagementService may differ). + +**CLI conventions:** System.CommandLine; JSON default + `--format table` opt-in. The CLI connects via the HTTP Management API (per CLAUDE.md). Mirror `src/ScadaLink.CLI/Commands/AuditLogCommands.cs` for the System.CommandLine pattern. + +**Bundles:** +- Bundle A — CLI `audit` command group: scaffold + query + export + verify-chain (T1, T2, T3, T4). +- Bundle B — ManagementService /api/audit/{query,export} endpoints (T5). +- Bundle C — Output formatters + audit-config rename + README (T6, T7, T8). + +Final cross-bundle review + merge + roadmap closeout. diff --git a/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs new file mode 100644 index 0000000..876db62 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs @@ -0,0 +1,72 @@ +using System.CommandLine; +using System.CommandLine.Parsing; + +namespace ScadaLink.CLI.Commands; + +/// +/// Resolved Management API connection details for an audit subcommand, or an +/// error describing why resolution failed. +/// +public sealed class AuditConnection +{ + public string? Url { get; init; } + public string? Username { get; init; } + public string? Password { get; init; } + public string? Error { get; init; } + public string? ErrorCode { get; init; } + + public static AuditConnection Fail(string error, string code) + => new() { Error = error, ErrorCode = code }; +} + +/// +/// Connection/format resolution shared by the audit subcommands. Mirrors the URL +/// and credential precedence used by (command line → config +/// file / environment), but produces a raw target +/// because the audit endpoints are plain REST resources rather than POST /management +/// command-envelope calls. +/// +public static class AuditCommandHelpers +{ + public static AuditConnection ResolveConnection( + ParseResult result, + Option urlOption, + Option usernameOption, + Option passwordOption) + { + var config = CliConfig.Load(); + + var url = result.GetValue(urlOption); + if (string.IsNullOrWhiteSpace(url)) + url = config.ManagementUrl; + + if (string.IsNullOrWhiteSpace(url)) + { + return AuditConnection.Fail( + "No management URL specified. Use --url, set SCADALINK_MANAGEMENT_URL, or add 'managementUrl' to ~/.scadalink/config.json.", + "NO_URL"); + } + + if (!CommandHelpers.IsValidManagementUrl(url)) + { + return AuditConnection.Fail( + $"Invalid management URL '{url}'. Expected an absolute http/https URL (e.g. http://localhost:9001).", + "INVALID_URL"); + } + + var username = CommandHelpers.ResolveCredential(result.GetValue(usernameOption), config.Username); + var password = CommandHelpers.ResolveCredential(result.GetValue(passwordOption), config.Password); + + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password)) + { + return AuditConnection.Fail( + "Credentials required. Use --username/--password or set SCADALINK_USERNAME/SCADALINK_PASSWORD.", + "NO_CREDENTIALS"); + } + + return new AuditConnection { Url = url, Username = username, Password = password }; + } + + public static string ResolveFormat(ParseResult result, Option formatOption) + => CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load()); +} diff --git a/src/ScadaLink.CLI/Commands/AuditCommands.cs b/src/ScadaLink.CLI/Commands/AuditCommands.cs new file mode 100644 index 0000000..b411b91 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditCommands.cs @@ -0,0 +1,190 @@ +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" }; + var channelOption = new Option("--channel") { Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound)" }; + 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)" }; + 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)" }; + statusOption.AcceptOnlyFromAmong( + "Submitted", "Forwarded", "Attempted", "Delivered", "Failed", "Parked", "Discarded", "Skipped"); + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + 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 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(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), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + Target = result.GetValue(targetOption), + Actor = result.GetValue(actorOption), + CorrelationId = result.GetValue(correlationIdOption), + 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 }; + var channelOption = new Option("--channel") { Description = "Filter by channel" }; + var kindOption = new Option("--kind") { Description = "Filter by event kind" }; + var statusOption = new Option("--status") { Description = "Filter by status" }; + var siteOption = new Option("--site") { Description = "Filter by source site ID" }; + 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), + Kind = result.GetValue(kindOption), + Status = result.GetValue(statusOption), + Site = result.GetValue(siteOption), + 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; + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs new file mode 100644 index 0000000..01d702b --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditExportHelpers.cs @@ -0,0 +1,114 @@ +using System.Globalization; +using System.Net; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter + destination arguments for an audit export invocation. Mirrors the +/// Bundle B GET /api/audit/export parameters. +/// +public sealed class AuditExportArgs +{ + public string Since { get; set; } = string.Empty; + public string Until { get; set; } = string.Empty; + public string Format { get; set; } = string.Empty; + public string Output { get; set; } = string.Empty; + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } +} + +/// +/// Helpers for the audit export subcommand: builds the export query string and +/// streams the HTTP response body straight to the destination file without buffering +/// the (potentially multi-megabyte) export in memory. +/// +public static class AuditExportHelpers +{ + /// + /// Builds the ?... query string for GET /api/audit/export: the required + /// time window + format, plus optional filters. Time-specs are resolved via + /// . + /// + public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + Add("fromUtc", AuditQueryHelpers.ResolveTimeSpec(args.Since, now).ToString("o", CultureInfo.InvariantCulture)); + Add("toUtc", AuditQueryHelpers.ResolveTimeSpec(args.Until, now).ToString("o", CultureInfo.InvariantCulture)); + Add("format", args.Format); + Add("channel", args.Channel); + Add("kind", args.Kind); + Add("status", args.Status); + Add("sourceSiteId", args.Site); + Add("target", args.Target); + Add("actor", args.Actor); + + return "?" + string.Join("&", parts); + } + + /// + /// Executes the export: GETs /api/audit/export and copies the response body + /// stream directly to . The body is never fully + /// buffered — streams in fixed-size chunks. + /// A 501 Not Implemented (parquet not yet supported server-side) prints the + /// server message and returns a non-zero exit code. + /// + public static async Task RunExportAsync( + ManagementHttpClient client, AuditExportArgs args, TextWriter output, DateTimeOffset now) + { + var qs = BuildQueryString(args, now); + + HttpResponseMessage response; + try + { + response = await client.SendGetStreamAsync("api/audit/export" + qs, CancellationToken.None); + } + catch (HttpRequestException ex) + { + OutputFormatter.WriteError($"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + return 1; + } + + using (response) + { + if (response.StatusCode == HttpStatusCode.NotImplemented) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) + ? "Export format not implemented by the server." + : message, + "NOT_IMPLEMENTED"); + return 1; + } + + if (!response.IsSuccessStatusCode) + { + var message = await response.Content.ReadAsStringAsync(); + OutputFormatter.WriteError( + string.IsNullOrWhiteSpace(message) ? $"Export failed (HTTP {(int)response.StatusCode})." : message, + "ERROR"); + return 1; + } + + await using var source = await response.Content.ReadAsStreamAsync(); + await using var destination = new FileStream( + args.Output, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + await source.CopyToAsync(destination); + } + + output.WriteLine($"Exported audit log to {args.Output}"); + return 0; + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditFormatter.cs b/src/ScadaLink.CLI/Commands/AuditFormatter.cs new file mode 100644 index 0000000..ebfb600 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditFormatter.cs @@ -0,0 +1,45 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Renders a page of audit-log events to a writer. The audit query command picks +/// a formatter from the --format option. The default JSONL formatter is defined +/// here; the human-readable table formatter is supplied by Bundle C. +/// +public interface IAuditFormatter +{ + /// Renders one page of events. Called once per fetched page. + void WritePage(IReadOnlyList events, TextWriter output); +} + +/// +/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events +/// are flushed as they arrive, so --all over many pages does not accumulate. +/// +public sealed class JsonLinesAuditFormatter : IAuditFormatter +{ + private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false }; + + public void WritePage(IReadOnlyList events, TextWriter output) + { + foreach (var evt in events) + output.WriteLine(JsonSerializer.Serialize(evt, Compact)); + } +} + +/// +/// Resolves an for a given --format value: +/// table renders a column-aligned text table (), +/// any other value (including json) renders JSONL. +/// +public static class AuditFormatterFactory +{ + public static IAuditFormatter Create(string format, TextWriter notices) + { + if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase)) + return new TableAuditFormatter(); + + return new JsonLinesAuditFormatter(); + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs index 00fc084..eee6143 100644 --- a/src/ScadaLink.CLI/Commands/AuditLogCommands.cs +++ b/src/ScadaLink.CLI/Commands/AuditLogCommands.cs @@ -4,11 +4,50 @@ using ScadaLink.Commons.Messages.Management; namespace ScadaLink.CLI.Commands; +/// +/// The scadalink audit-config command group: views the configuration-change +/// audit log (the IAuditService trail of admin edits — distinct from the +/// centralized append-only Audit Log served by ). +/// +/// +/// Renamed from audit-log in #23 M8-T7 to avoid confusion with the new +/// scadalink audit group. The old audit-log name is retained as a +/// deprecated alias; still resolves the full subcommand +/// tree, and Program.cs prints a deprecation warning when it is used. +/// public static class AuditLogCommands { + /// The deprecated alias kept for backward compatibility with the old command name. + public const string DeprecatedAlias = "audit-log"; + + /// The deprecation warning emitted when the old audit-log name is used. + public const string DeprecationWarning = + "Warning: 'audit-log' is deprecated and will be removed in a future release. " + + "Use 'audit-config' instead."; + + /// + /// Writes the to when the + /// CLI was invoked via the deprecated audit-log command name (i.e. the first + /// argument is ). The command itself still works — it is + /// an alias of audit-config — so this only adds the migration warning. + /// Factored out of Program.cs so it is unit-testable without spawning a process. + /// + public static void WriteDeprecationWarningIfNeeded(string[] args, TextWriter stderr) + { + if (args.Length > 0 + && string.Equals(args[0], DeprecatedAlias, StringComparison.Ordinal)) + { + stderr.WriteLine(DeprecationWarning); + } + } + public static Command Build(Option urlOption, Option formatOption, Option usernameOption, Option passwordOption) { - var command = new Command("audit-log") { Description = "Query audit logs" }; + var command = new Command("audit-config") { Description = "Query the configuration-change audit log" }; + // Backward-compatible alias for the pre-M8 `audit-log` name. The alias keeps + // full subcommand parity automatically; the deprecation warning is emitted by + // the args[0] check in Program.cs. + command.Aliases.Add(DeprecatedAlias); command.Add(BuildQuery(urlOption, formatOption, usernameOption, passwordOption)); diff --git a/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs new file mode 100644 index 0000000..f18f971 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace ScadaLink.CLI.Commands; + +/// +/// Filter arguments for an audit query invocation. Mirrors the Bundle B +/// GET /api/audit/query filter parameters; / +/// are time-specs (relative like 1h/7d, or absolute ISO-8601). +/// +public sealed class AuditQueryArgs +{ + public string? Since { get; set; } + public string? Until { get; set; } + public string? Channel { get; set; } + public string? Kind { get; set; } + public string? Status { get; set; } + public string? Site { get; set; } + public string? Target { get; set; } + public string? Actor { get; set; } + public string? CorrelationId { get; set; } + public bool ErrorsOnly { get; set; } + public int PageSize { get; set; } = 100; +} + +/// +/// Pure helpers for the audit query subcommand: time-spec resolution, query-string +/// construction, and the keyset-cursor paging loop. Kept separate from the command wiring +/// so each piece is unit-testable without standing up the command tree. +/// +public static class AuditQueryHelpers +{ + // where unit is s/m/h/d — a relative offset back from "now". + private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled); + + /// + /// Resolves a time-spec to an absolute . Accepts a + /// relative offset (30s, 15m, 1h, 7d) interpreted as + /// minus the offset, or an absolute ISO-8601 timestamp. + /// + /// The spec is neither a known relative form nor a parseable ISO-8601 timestamp. + public static DateTimeOffset ResolveTimeSpec(string spec, DateTimeOffset now) + { + if (string.IsNullOrWhiteSpace(spec)) + throw new FormatException("Empty time value."); + + var match = RelativeSpec.Match(spec.Trim()); + if (match.Success) + { + var amount = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); + var offset = match.Groups[2].Value switch + { + "s" => TimeSpan.FromSeconds(amount), + "m" => TimeSpan.FromMinutes(amount), + "h" => TimeSpan.FromHours(amount), + "d" => TimeSpan.FromDays(amount), + _ => throw new FormatException($"Unknown time unit in '{spec}'."), + }; + return now - offset; + } + + if (DateTimeOffset.TryParse(spec, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var absolute)) + { + return absolute; + } + + throw new FormatException( + $"Invalid time value '{spec}'. Use a relative offset (e.g. 1h, 24h, 7d) or an ISO-8601 timestamp."); + } + + /// + /// Builds the ?... query string for GET /api/audit/query from the filter + /// args plus an optional keyset cursor. Unset filters are omitted. --errors-only + /// maps to status=Failed (the server takes a single status value). + /// + public static string BuildQueryString( + AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId) + { + var parts = new List(); + + void Add(string key, string? value) + { + if (!string.IsNullOrWhiteSpace(value)) + parts.Add($"{key}={Uri.EscapeDataString(value)}"); + } + + if (!string.IsNullOrWhiteSpace(args.Since)) + Add("fromUtc", ResolveTimeSpec(args.Since!, now).ToString("o", CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(args.Until)) + Add("toUtc", ResolveTimeSpec(args.Until!, now).ToString("o", CultureInfo.InvariantCulture)); + + Add("channel", args.Channel); + Add("kind", args.Kind); + + // --errors-only is a convenience shorthand for the single-value Failed status + // filter. The server's status filter accepts one value, so --errors-only and an + // explicit --status are mutually exclusive in effect; --errors-only wins. + Add("status", args.ErrorsOnly ? "Failed" : args.Status); + + Add("sourceSiteId", args.Site); + Add("target", args.Target); + Add("actor", args.Actor); + Add("correlationId", args.CorrelationId); + Add("pageSize", args.PageSize.ToString(CultureInfo.InvariantCulture)); + + if (afterOccurredAtUtc.HasValue) + Add("afterOccurredAtUtc", afterOccurredAtUtc.Value.ToString("o", CultureInfo.InvariantCulture)); + Add("afterEventId", afterEventId); + + return parts.Count == 0 ? string.Empty : "?" + string.Join("&", parts); + } + + /// + /// Executes the query: GETs /api/audit/query, renders each page with + /// , and — when is set — + /// follows nextCursor until the server returns a null cursor. Returns the + /// process exit code (0 success, non-zero on HTTP/transport error). + /// + public static async Task RunQueryAsync( + ManagementHttpClient client, + AuditQueryArgs args, + bool fetchAll, + IAuditFormatter formatter, + TextWriter output, + DateTimeOffset now) + { + DateTimeOffset? afterOccurredAtUtc = null; + string? afterEventId = null; + + while (true) + { + var qs = BuildQueryString(args, now, afterOccurredAtUtc, afterEventId); + var response = await client.SendGetAsync("api/audit/query" + qs, TimeSpan.FromSeconds(30)); + + if (response.JsonData == null) + { + OutputFormatter.WriteError( + response.Error ?? "Audit query failed.", response.ErrorCode ?? "ERROR"); + return 1; + } + + using var doc = JsonDocument.Parse(response.JsonData); + var root = doc.RootElement; + + var events = root.TryGetProperty("events", out var evts) && evts.ValueKind == JsonValueKind.Array + ? evts.EnumerateArray().ToList() + : new List(); + formatter.WritePage(events, output); + output.Flush(); + + if (!fetchAll) + return 0; + + if (!root.TryGetProperty("nextCursor", out var cursor) + || cursor.ValueKind != JsonValueKind.Object) + { + return 0; + } + + afterOccurredAtUtc = cursor.TryGetProperty("afterOccurredAtUtc", out var c1) + && c1.ValueKind == JsonValueKind.String + ? DateTimeOffset.Parse(c1.GetString()!, CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal) + : null; + afterEventId = cursor.TryGetProperty("afterEventId", out var c2) + && c2.ValueKind == JsonValueKind.String + ? c2.GetString() + : null; + + // A malformed cursor (object present but missing both keys) would loop + // forever — treat it as the end of results. + if (afterOccurredAtUtc == null && afterEventId == null) + return 0; + } + } +} diff --git a/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs new file mode 100644 index 0000000..45aa964 --- /dev/null +++ b/src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs @@ -0,0 +1,20 @@ +using System.Globalization; + +namespace ScadaLink.CLI.Commands; + +/// +/// Helpers for the audit verify-chain subcommand. v1 is a no-op: hash-chain +/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still +/// validates its --month argument so the surface is stable for v1.x. +/// +public static class AuditVerifyChainHelpers +{ + /// + /// Returns true if is a well-formed YYYY-MM value + /// with a real month (01-12). A malformed month (e.g. 2026-13) is rejected. + /// + public static bool IsValidMonth(string? month) + => !string.IsNullOrWhiteSpace(month) + && DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture, + DateTimeStyles.None, out _); +} diff --git a/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs new file mode 100644 index 0000000..98c9b6c --- /dev/null +++ b/src/ScadaLink.CLI/Commands/TableAuditFormatter.cs @@ -0,0 +1,96 @@ +using System.Text.Json; + +namespace ScadaLink.CLI.Commands; + +/// +/// Human-readable table formatter for audit query --format table (Audit Log +/// #23 M8-T6). Renders each fetched page as a column-aligned text table with a fixed +/// column set (). Long free-text fields (Target, Actor) are +/// truncated with an ellipsis so columns stay aligned regardless of payload size. +/// +/// +/// A header row is emitted once per page (matching the streamable, page-at-a-time +/// contract of ). An empty page emits the header only, +/// so the column shape is visible even with zero results. +/// +public sealed class TableAuditFormatter : IAuditFormatter +{ + /// JSON property name (camelCase, as the server serializes it) → column header. + private static readonly (string Property, string Header, int MaxWidth)[] Columns = + { + ("occurredAtUtc", "OccurredAtUtc", 24), + ("channel", "Channel", 14), + ("kind", "Kind", 18), + ("status", "Status", 12), + ("target", "Target", 32), + ("actor", "Actor", 20), + ("durationMs", "DurationMs", 10), + ("httpStatus", "HttpStatus", 10), + }; + + public void WritePage(IReadOnlyList events, TextWriter output) + { + // Build every cell first so column widths account for the actual data. + var rows = new List(events.Count); + foreach (var evt in events) + { + var cells = new string[Columns.Length]; + for (var i = 0; i < Columns.Length; i++) + cells[i] = Truncate(CellValue(evt, Columns[i].Property), Columns[i].MaxWidth); + rows.Add(cells); + } + + var widths = new int[Columns.Length]; + for (var i = 0; i < Columns.Length; i++) + widths[i] = Columns[i].Header.Length; + foreach (var row in rows) + for (var i = 0; i < Columns.Length; i++) + widths[i] = Math.Max(widths[i], row[i].Length); + + WriteRow(output, Columns.Select(c => c.Header).ToArray(), widths); + foreach (var row in rows) + WriteRow(output, row, widths); + } + + /// + /// Extracts a cell value for from an audit event. + /// A missing property or a JSON null renders as an empty string (never + /// the literal text "null"). + /// + private static string CellValue(JsonElement evt, string property) + { + if (evt.ValueKind != JsonValueKind.Object + || !evt.TryGetProperty(property, out var value) + || value.ValueKind == JsonValueKind.Null) + { + return string.Empty; + } + + return value.ValueKind == JsonValueKind.String + ? value.GetString() ?? string.Empty + : value.ToString(); + } + + /// + /// Truncates to characters, + /// replacing the tail with a single-character ellipsis so the column stays aligned. + /// + private static string Truncate(string value, int maxWidth) + { + if (maxWidth <= 0 || value.Length <= maxWidth) + return value; + if (maxWidth == 1) + return "…"; + return value.Substring(0, maxWidth - 1) + "…"; + } + + private static void WriteRow(TextWriter output, IReadOnlyList cells, int[] widths) + { + for (var i = 0; i < cells.Count; i++) + { + // Last column is not padded — avoids trailing whitespace at line end. + output.Write(i == cells.Count - 1 ? cells[i] : cells[i].PadRight(widths[i] + 2)); + } + output.WriteLine(); + } +} diff --git a/src/ScadaLink.CLI/ManagementHttpClient.cs b/src/ScadaLink.CLI/ManagementHttpClient.cs index 515bf42..ce8e02b 100644 --- a/src/ScadaLink.CLI/ManagementHttpClient.cs +++ b/src/ScadaLink.CLI/ManagementHttpClient.cs @@ -74,6 +74,65 @@ public class ManagementHttpClient : IDisposable return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); } + /// + /// Issues a plain HTTP GET against a REST endpoint (e.g. the audit + /// /api/audit/query endpoint introduced by Audit Log #23 M8) and returns the + /// response body. Unlike , this does not wrap the call + /// in the POST /management command envelope — the audit endpoints are plain + /// REST resources. Authentication (HTTP Basic) and the base address are shared. + /// + /// Path relative to the base URL, with query string. + public async Task SendGetAsync(string relativePath, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + + HttpResponseMessage httpResponse; + try + { + httpResponse = await _httpClient.GetAsync(relativePath, cts.Token); + } + catch (TaskCanceledException) + { + return new ManagementResponse(504, null, "Request timed out.", "TIMEOUT"); + } + catch (HttpRequestException ex) + { + return new ManagementResponse(0, null, $"Connection failed: {ex.Message}", "CONNECTION_FAILED"); + } + + var responseBody = await httpResponse.Content.ReadAsStringAsync(cts.Token); + + if (httpResponse.IsSuccessStatusCode) + { + return new ManagementResponse((int)httpResponse.StatusCode, responseBody, null, null); + } + + string? error = null; + string? code = null; + try + { + using var doc = JsonDocument.Parse(responseBody); + error = doc.RootElement.TryGetProperty("error", out var e) ? e.GetString() : responseBody; + code = doc.RootElement.TryGetProperty("code", out var c) ? c.GetString() : null; + } + catch + { + error = responseBody; + } + + return new ManagementResponse((int)httpResponse.StatusCode, null, error, code); + } + + /// + /// Issues a plain HTTP GET and returns the raw + /// so the caller can stream the response body without buffering it in memory — used + /// by audit export, where the response can be many megabytes. The caller owns + /// disposing the returned message. The + /// option ensures the body is not pre-buffered. + /// + public async Task SendGetStreamAsync(string relativePath, CancellationToken cancellationToken) + => await _httpClient.GetAsync(relativePath, HttpCompletionOption.ResponseHeadersRead, cancellationToken); + public void Dispose() => _httpClient.Dispose(); } diff --git a/src/ScadaLink.CLI/Program.cs b/src/ScadaLink.CLI/Program.cs index 08da7d7..240b410 100644 --- a/src/ScadaLink.CLI/Program.cs +++ b/src/ScadaLink.CLI/Program.cs @@ -27,6 +27,7 @@ rootCommand.Add(ExternalSystemCommands.Build(urlOption, formatOption, usernameOp rootCommand.Add(NotificationCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SecurityCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(AuditLogCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); +rootCommand.Add(AuditCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(HealthCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(DebugCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); rootCommand.Add(SharedScriptCommands.Build(urlOption, formatOption, usernameOption, passwordOption)); @@ -38,5 +39,10 @@ rootCommand.SetAction(_ => Console.WriteLine("Use --help to see available commands."); }); +// Deprecation notice for the pre-M8 `audit-log` command name. The command itself +// still works (it is an alias of `audit-config`), but using the old name emits a +// warning to stderr so scripts can be migrated. +AuditLogCommands.WriteDeprecationWarningIfNeeded(args, Console.Error); + var parseResult = CommandLineParser.Parse(rootCommand, args); return await parseResult.InvokeAsync(); diff --git a/src/ScadaLink.CLI/README.md b/src/ScadaLink.CLI/README.md index 134af60..eed1ccf 100644 --- a/src/ScadaLink.CLI/README.md +++ b/src/ScadaLink.CLI/README.md @@ -1049,14 +1049,118 @@ Features: --- -### `audit-log` — Audit log queries +### `audit` — Centralized Audit Log -#### `audit-log query` +Read access to the central append-only **Audit Log** (#23) — the record of every +script-trust-boundary action: outbound API calls (sync + cached), outbound DB +operations (sync + cached), notifications, and inbound API calls. This is distinct +from the configuration-change audit trail exposed by [`audit-config`](#audit-config--configuration-change-audit-log). -Query the central audit log with optional filters and pagination. +The subcommands map directly onto the `GET /api/audit/query` and +`GET /api/audit/export` management endpoints. Filters and the result columns mirror +the Central UI **Audit** page, so a CLI query and a UI query with the same filters +return the same rows — CLI ↔ UI filter parity is intentional. + +**Permissions.** Querying requires the `OperationalAudit` permission (roles `Admin`, +`Audit`, or `AuditReadOnly`). Exporting requires the stricter `AuditExport` permission +(roles `Admin` or `Audit`) — read access does *not* imply export access. A request +without the required role returns exit code `2`. + +#### `audit query` + +Query audit log events with optional filters and keyset pagination. ```sh -scadalink --url audit-log query [options] +scadalink --url audit query [options] +``` + +| Option | Required | Default | Description | +|--------|----------|---------|-------------| +| `--since` | no | — | Start time: relative (`1h`, `24h`, `7d`) or ISO-8601 | +| `--until` | no | — | End time: relative (`1h`, `24h`, `7d`) or ISO-8601 | +| `--channel` | no | — | Filter by channel (`ApiOutbound`, `DbOutbound`, `Notification`, `ApiInbound`) | +| `--kind` | no | — | Filter by event kind (`ApiCall`, `ApiCallCached`, `DbWrite`, `DbWriteCached`, `NotifySend`, `NotifyDeliver`, `InboundRequest`, `InboundAuthFailure`, `CachedSubmit`, `CachedResolve`) | +| `--status` | no | — | Filter by status (`Submitted`, `Forwarded`, `Attempted`, `Delivered`, `Failed`, `Parked`, `Discarded`, `Skipped`) | +| `--site` | no | — | Filter by source site ID | +| `--target` | no | — | Filter by target (external system, DB connection, notification list) | +| `--actor` | no | — | Filter by actor | +| `--correlation-id` | no | — | Filter by correlation ID | +| `--errors-only` | no | `false` | Show only failed events (`status=Failed`; overrides `--status`) | +| `--page-size` | no | `100` | Events per page (1–1000) | +| `--all` | no | `false` | Fetch every page, following the keyset cursor | +| `--format` | no | `json` | Output format: `json` (JSONL, one event per line) or `table` | + +With `--format table`, events render as an aligned text table with columns +`OccurredAtUtc`, `Channel`, `Kind`, `Status`, `Target`, `Actor`, `DurationMs`, +`HttpStatus`; long `Target`/`Actor` values are truncated with an ellipsis. With +`--format json` (the default), each page is emitted as JSONL — one JSON object per +line — which streams cleanly under `--all` across many pages. + +#### `audit export` + +Export audit log events to a file. The export streams from the server, so it is not +bounded by the query page size. + +```sh +scadalink --url audit export --since