feat(cli): scadalink audit query subcommand (#23 M8)
This commit is contained in:
72
src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
Normal file
72
src/ScadaLink.CLI/Commands/AuditCommandHelpers.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
using System.CommandLine;
|
||||
using System.CommandLine.Parsing;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved Management API connection details for an <c>audit</c> subcommand, or an
|
||||
/// error describing why resolution failed.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connection/format resolution shared by the <c>audit</c> subcommands. Mirrors the URL
|
||||
/// and credential precedence used by <see cref="CommandHelpers"/> (command line → config
|
||||
/// file / environment), but produces a raw <see cref="ManagementHttpClient"/> target
|
||||
/// because the audit endpoints are plain REST resources rather than <c>POST /management</c>
|
||||
/// command-envelope calls.
|
||||
/// </summary>
|
||||
public static class AuditCommandHelpers
|
||||
{
|
||||
public static AuditConnection ResolveConnection(
|
||||
ParseResult result,
|
||||
Option<string> urlOption,
|
||||
Option<string> usernameOption,
|
||||
Option<string> 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<string> formatOption)
|
||||
=> CommandHelpers.ResolveFormat(result, formatOption, CliConfig.Load());
|
||||
}
|
||||
@@ -24,22 +24,164 @@ public static class AuditCommands
|
||||
|
||||
private static Command BuildQuery(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sinceOption = new Option<string?>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
var untilOption = new Option<string?>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601" };
|
||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel (OutboundApi, OutboundDb, Notification, InboundApi)" };
|
||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status (single value)" };
|
||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
||||
var instanceOption = new Option<string?>("--instance") { Description = "Filter by instance" };
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target (external system, DB connection, notification list)" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
var correlationIdOption = new Option<string?>("--correlation-id") { Description = "Filter by correlation ID" };
|
||||
var errorsOnlyOption = new Option<bool>("--errors-only") { Description = "Show only failed events (status=Failed; overrides --status)" };
|
||||
var pageSizeOption = new Option<int>("--page-size") { Description = "Events per page (1-1000)" };
|
||||
pageSizeOption.DefaultValueFactory = _ => 100;
|
||||
var allOption = new Option<bool>("--all") { Description = "Fetch every page, following the keyset cursor" };
|
||||
|
||||
var cmd = new Command("query") { Description = "Query audit log events" };
|
||||
cmd.SetAction((ParseResult result) => 0);
|
||||
cmd.Add(sinceOption);
|
||||
cmd.Add(untilOption);
|
||||
cmd.Add(channelOption);
|
||||
cmd.Add(kindOption);
|
||||
cmd.Add(statusOption);
|
||||
cmd.Add(siteOption);
|
||||
cmd.Add(instanceOption);
|
||||
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),
|
||||
Instance = result.GetValue(instanceOption),
|
||||
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var sinceOption = new Option<string>("--since") { Description = "Start time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var untilOption = new Option<string>("--until") { Description = "End time: relative (1h, 24h, 7d) or ISO-8601", Required = true };
|
||||
var formatExportOption = new Option<string>("--format") { Description = "Export format", Required = true };
|
||||
formatExportOption.AcceptOnlyFromAmong("csv", "jsonl", "parquet");
|
||||
var outputOption = new Option<string>("--output") { Description = "Destination file path", Required = true };
|
||||
var channelOption = new Option<string?>("--channel") { Description = "Filter by channel" };
|
||||
var kindOption = new Option<string?>("--kind") { Description = "Filter by event kind" };
|
||||
var statusOption = new Option<string?>("--status") { Description = "Filter by status" };
|
||||
var siteOption = new Option<string?>("--site") { Description = "Filter by source site ID" };
|
||||
var targetOption = new Option<string?>("--target") { Description = "Filter by target" };
|
||||
var actorOption = new Option<string?>("--actor") { Description = "Filter by actor" };
|
||||
|
||||
var cmd = new Command("export") { Description = "Export audit log events to a file" };
|
||||
cmd.SetAction((ParseResult result) => 0);
|
||||
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var monthOption = new Option<string>("--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.SetAction((ParseResult result) => 0);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
114
src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
Normal file
114
src/ScadaLink.CLI/Commands/AuditExportHelpers.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter + destination arguments for an <c>audit export</c> invocation. Mirrors the
|
||||
/// Bundle B <c>GET /api/audit/export</c> parameters.
|
||||
/// </summary>
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit export</c> 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.
|
||||
/// </summary>
|
||||
public static class AuditExportHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/export</c>: the required
|
||||
/// time window + format, plus optional filters. Time-specs are resolved via
|
||||
/// <see cref="AuditQueryHelpers.ResolveTimeSpec"/>.
|
||||
/// </summary>
|
||||
public static string BuildQueryString(AuditExportArgs args, DateTimeOffset now)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the export: GETs <c>/api/audit/export</c> and copies the response body
|
||||
/// stream directly to <see cref="AuditExportArgs.Output"/>. The body is never fully
|
||||
/// buffered — <see cref="Stream.CopyToAsync(Stream)"/> streams in fixed-size chunks.
|
||||
/// A <c>501 Not Implemented</c> (parquet not yet supported server-side) prints the
|
||||
/// server message and returns a non-zero exit code.
|
||||
/// </summary>
|
||||
public static async Task<int> 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;
|
||||
}
|
||||
}
|
||||
48
src/ScadaLink.CLI/Commands/AuditFormatter.cs
Normal file
48
src/ScadaLink.CLI/Commands/AuditFormatter.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Renders a page of audit-log events to a writer. The <c>audit query</c> command picks
|
||||
/// a formatter from the <c>--format</c> option. The default JSONL formatter is defined
|
||||
/// here; the human-readable table formatter is supplied by Bundle C.
|
||||
/// </summary>
|
||||
public interface IAuditFormatter
|
||||
{
|
||||
/// <summary>Renders one page of events. Called once per fetched page.</summary>
|
||||
void WritePage(IReadOnlyList<JsonElement> events, TextWriter output);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default formatter: one JSON object per line (JSONL). Streamable — each page's events
|
||||
/// are flushed as they arrive, so <c>--all</c> over many pages does not accumulate.
|
||||
/// </summary>
|
||||
public sealed class JsonLinesAuditFormatter : IAuditFormatter
|
||||
{
|
||||
private static readonly JsonSerializerOptions Compact = new() { WriteIndented = false };
|
||||
|
||||
public void WritePage(IReadOnlyList<JsonElement> events, TextWriter output)
|
||||
{
|
||||
foreach (var evt in events)
|
||||
output.WriteLine(JsonSerializer.Serialize(evt, Compact));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an <see cref="IAuditFormatter"/> for a given <c>--format</c> value. The
|
||||
/// table formatter is filled in by Bundle C; until then <c>--format table</c> falls back
|
||||
/// to JSONL with a one-time notice so the flag is wired but not silently broken.
|
||||
/// </summary>
|
||||
public static class AuditFormatterFactory
|
||||
{
|
||||
public static IAuditFormatter Create(string format, TextWriter notices)
|
||||
{
|
||||
if (string.Equals(format, "table", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
notices.WriteLine("note: 'table' output is not yet available; using json. (Bundle C)");
|
||||
return new JsonLinesAuditFormatter();
|
||||
}
|
||||
|
||||
return new JsonLinesAuditFormatter();
|
||||
}
|
||||
}
|
||||
181
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
181
src/ScadaLink.CLI/Commands/AuditQueryHelpers.cs
Normal file
@@ -0,0 +1,181 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Filter arguments for an <c>audit query</c> invocation. Mirrors the Bundle B
|
||||
/// <c>GET /api/audit/query</c> filter parameters; <see cref="Since"/>/<see cref="Until"/>
|
||||
/// are time-specs (relative like <c>1h</c>/<c>7d</c>, or absolute ISO-8601).
|
||||
/// </summary>
|
||||
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? Instance { 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure helpers for the <c>audit query</c> 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.
|
||||
/// </summary>
|
||||
public static class AuditQueryHelpers
|
||||
{
|
||||
// <number><unit> where unit is s/m/h/d — a relative offset back from "now".
|
||||
private static readonly Regex RelativeSpec = new(@"^(\d+)([smhd])$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a time-spec to an absolute <see cref="DateTimeOffset"/>. Accepts a
|
||||
/// relative offset (<c>30s</c>, <c>15m</c>, <c>1h</c>, <c>7d</c>) interpreted as
|
||||
/// <paramref name="now"/> minus the offset, or an absolute ISO-8601 timestamp.
|
||||
/// </summary>
|
||||
/// <exception cref="FormatException">The spec is neither a known relative form nor a parseable ISO-8601 timestamp.</exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the <c>?...</c> query string for <c>GET /api/audit/query</c> from the filter
|
||||
/// args plus an optional keyset cursor. Unset filters are omitted. <c>--errors-only</c>
|
||||
/// maps to <c>status=Failed</c> (the server takes a single status value).
|
||||
/// </summary>
|
||||
public static string BuildQueryString(
|
||||
AuditQueryArgs args, DateTimeOffset now, DateTimeOffset? afterOccurredAtUtc, string? afterEventId)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
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("instance", args.Instance);
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the query: GETs <c>/api/audit/query</c>, renders each page with
|
||||
/// <paramref name="formatter"/>, and — when <paramref name="fetchAll"/> is set —
|
||||
/// follows <c>nextCursor</c> until the server returns a null cursor. Returns the
|
||||
/// process exit code (0 success, non-zero on HTTP/transport error).
|
||||
/// </summary>
|
||||
public static async Task<int> 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<JsonElement>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
Normal file
20
src/ScadaLink.CLI/Commands/AuditVerifyChainHelpers.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace ScadaLink.CLI.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for the <c>audit verify-chain</c> subcommand. v1 is a no-op: hash-chain
|
||||
/// tamper-evidence is deferred to v1.x (see Component-AuditLog.md). The command still
|
||||
/// validates its <c>--month</c> argument so the surface is stable for v1.x.
|
||||
/// </summary>
|
||||
public static class AuditVerifyChainHelpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="month"/> is a well-formed <c>YYYY-MM</c> value
|
||||
/// with a real month (01-12). A malformed month (e.g. <c>2026-13</c>) is rejected.
|
||||
/// </summary>
|
||||
public static bool IsValidMonth(string? month)
|
||||
=> !string.IsNullOrWhiteSpace(month)
|
||||
&& DateTime.TryParseExact(month, "yyyy-MM", CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.None, out _);
|
||||
}
|
||||
Reference in New Issue
Block a user