Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/AuditCommands.cs
T
Joseph Doherty b95ee5a658 feat(audit): archive retention and superseded stale-archive cleanup (ScadaBridge#109)
Owner decisions recorded: the archive destination stays a directory/UNC
(no object-storage client; share + permissions named at enablement),
parquet-tools-only reading (no re-import), unsigned like the chain (#107).

AuditLog:Archive:RetentionDays (default 0 = never delete; else must exceed
AuditLog:RetentionDays, counted from the archived month's end) and
DeleteSupersededStaleArchives (default true) run after every purge tick.
A retried purge's stale Archived archive is deleted only when a later,
verifying, Purged archive of the same partition provably holds every row:
same prior anchor and the stale chain-end hash at its chain-end sequence
(row or tombstone), every unchained row present by EventId; otherwise it is
reported KeptStale. A sole copy left at Archived is never touched. Deletion
removes only the archive name's own three files in its folder, manifest
first, never a manifest-supplied path. `audit archive cleanup [--dry-run]`
= CleanupAuditArchives (Administrator).

AuditLogArchiveCleanupTests (MSSQL) cover retried purge, kept stale copy,
sole copy, retention and the path guard; each safety rule mutation-checked.
Follow-ups filed: #113, #114, #115.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 23:33:04 -05:00

505 lines
24 KiB
C#

using System.CommandLine;
using System.CommandLine.Parsing;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
namespace ZB.MOM.WW.ScadaBridge.CLI.Commands;
/// <summary>
/// The <c>scadabridge audit</c> command group. Provides read access to
/// the centralized append-only Audit Log via the REST endpoints
/// (<c>GET /api/audit/query</c>, <c>GET /api/audit/export</c>,
/// <c>GET /api/audit/tree</c>), plus <c>verify-chain</c>, which sends the
/// <c>VerifyAuditChain</c> management command (hash-chain tamper evidence, ScadaBridge#41),
/// <c>verify-heads</c>, which checks off-box exported chain heads with
/// <c>VerifyAuditChainHeads</c> (ScadaBridge#107), and <c>archive list</c> / <c>archive verify</c>
/// / <c>archive cleanup</c>, which send <c>ListAuditArchives</c> / <c>VerifyAuditArchive</c>
/// (Parquet cold archive, ScadaBridge#42) / <c>CleanupAuditArchives</c> (ScadaBridge#109).
/// </summary>
public static class AuditCommands
{
/// <summary>
/// Builds the <c>audit</c> command group with query, export, tree, and verify-chain
/// sub-commands.
/// </summary>
/// <param name="urlOption">Global <c>--url</c> option for the management API endpoint.</param>
/// <param name="formatOption">Global <c>--format</c> option for output format.</param>
/// <param name="usernameOption">Global <c>--username</c> option for authentication.</param>
/// <param name="passwordOption">Global <c>--password</c> option for authentication.</param>
/// <returns>The configured <c>audit</c> <see cref="Command"/> with all sub-commands attached.</returns>
public static Command Build(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<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" };
// --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<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
AllowMultipleArgumentsPerToken = true,
};
var kindOption = new Option<string[]>("--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<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
AllowMultipleArgumentsPerToken = true,
};
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
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 executionIdOption = new Option<string?>("--execution-id") { Description = "Filter by execution ID" };
var parentExecutionIdOption = new Option<string?>("--parent-execution-id") { Description = "Filter by parent execution 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.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<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
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<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 };
// 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<string>("--format")
{
Description = "Export format (csv, jsonl, parquet; case-insensitive)",
Required = true,
};
var outputOption = new Option<string>("--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<string[]>("--channel")
{
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound; case-insensitive); repeatable",
AllowMultipleArgumentsPerToken = true,
};
var kindOption = new Option<string[]>("--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<string[]>("--status")
{
Description = "Filter by status (Submitted, Forwarded, Attempted, Delivered, Failed, Parked, Discarded, Skipped; case-insensitive); repeatable",
AllowMultipleArgumentsPerToken = true,
};
var siteOption = new Option<string[]>("--site")
{
Description = "Filter by source site ID; repeatable",
AllowMultipleArgumentsPerToken = true,
};
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.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<string>(),
Kind = result.GetValue(kindOption) ?? Array.Empty<string>(),
Status = result.GetValue(statusOption) ?? Array.Empty<string>(),
Site = result.GetValue(siteOption) ?? Array.Empty<string>(),
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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var executionIdOption = new Option<string>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var monthOption = new Option<string>("--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;
}
/// <summary>
/// Builds the <c>audit archive</c> group (ScadaBridge#42): <c>list</c> and <c>verify</c>
/// over the AuditLog Parquet cold archives on the central archive directory.
/// </summary>
private static Command BuildVerifyHeads(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var pathOption = new Option<string>("--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<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> 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<string>("--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<bool>("--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;
}
/// <summary>
/// Builds the <c>audit backfill-source-node</c> sub-command.
/// Sets <c>SourceNode</c> on historical pre-feature rows whose <c>SourceNode IS NULL</c>
/// and <c>OccurredAtUtc</c> is older than <c>--before</c>, in batches. Admin-only.
/// </summary>
private static Command BuildBackfillSourceNode(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var sentinelOption = new Option<string>("--sentinel")
{
Description = "Value to write for pre-feature rows whose node-of-origin is unknown (default: unknown)",
};
sentinelOption.DefaultValueFactory = _ => "unknown";
var beforeOption = new Option<string>("--before")
{
Description = "ISO-8601 UTC datetime; only rows older than this date are eligible (required)",
Required = true,
};
var batchOption = new Option<int>("--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;
}
}