9cff87fe85
Remove project bookkeeping citations from shipped code comments across the solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/ issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels, and C/D/K/S/T phase labels. Comment text only — no code logic, string/log literals, or XML-doc structure changed. Genuine descriptions are preserved (only the citation is stripped), and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365, UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker TaskReferenceInComment / TrackingReferenceInComment checks plus targeted grep passes; full solution builds clean, append-only guard tests pass.
365 lines
18 KiB
C#
365 lines
18 KiB
C#
using System.CommandLine;
|
|
using System.CommandLine.Parsing;
|
|
|
|
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 a v1 no-op <c>verify-chain</c> placeholder
|
|
/// for the deferred hash-chain tamper-evidence feature.
|
|
/// </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(BuildBackfillSourceNode(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). AcceptOnlyFromAmong validates EACH supplied value.
|
|
var channelOption = new Option<string[]>("--channel")
|
|
{
|
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
|
var kindOption = new Option<string[]>("--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<string[]>("--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<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 };
|
|
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 };
|
|
// --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<string[]>("--channel")
|
|
{
|
|
Description = "Filter by channel (ApiOutbound, DbOutbound, Notification, ApiInbound); repeatable",
|
|
AllowMultipleArgumentsPerToken = true,
|
|
};
|
|
channelOption.AcceptOnlyFromAmong("ApiOutbound", "DbOutbound", "Notification", "ApiInbound");
|
|
var kindOption = new Option<string[]>("--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<string[]>("--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<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),
|
|
};
|
|
|
|
try
|
|
{
|
|
using var client = new ManagementHttpClient(connection.Url!, connection.Username!, connection.Password!);
|
|
return await AuditExportHelpers.RunExportAsync(client, args, Console.Out, DateTimeOffset.UtcNow);
|
|
}
|
|
catch (FormatException ex)
|
|
{
|
|
OutputFormatter.WriteError(ex.Message, "INVALID_ARGUMENT");
|
|
return 1;
|
|
}
|
|
});
|
|
return cmd;
|
|
}
|
|
|
|
private static Command BuildTree(Option<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 = "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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|