feat(management): parquet audit export and audit archive list/verify (ScadaBridge#42)

GET /api/audit/export?format=parquet returned 501. It now streams the same 24
columns as csv/jsonl (AuditExportRow, CsvHeader order) as one Parquet file,
one 10,000-row row group at a time. Each row group is encoded into a small
non-seekable staging buffer and copied to the response asynchronously, because
Kestrel rejects the synchronous writes the Parquet writer issues.

New management commands, with CLI parity:
- ListAuditArchives -> `audit archive list` (AuditArchiveListResult: enabled
  flag + every manifest).
- VerifyAuditArchive(Name) -> `audit archive verify --name` (files, chain,
  purge anchor); exit 0 verified, 3 not verified, 1 error, 2 unauthorized.
Both carry verify-chain's gate: OperationalAudit roles (Administrator, Viewer),
site-scoped non-Administrator refused because an archive spans every site.
VerifyAuditArchive uses the long-running Ask timeout. Error codes
ARCHIVE_NOT_CONFIGURED, INVALID_ARGUMENT, NOT_FOUND.

RequiredRoleMatrixTests freezes both gates; ManagementSurfaceParityTests now
probes `audit archive list|verify` alongside `audit verify-chain`.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 17:00:58 -05:00
parent 7be4f19861
commit d1fc336e18
17 changed files with 982 additions and 42 deletions
@@ -0,0 +1,219 @@
using Parquet;
using Parquet.Schema;
namespace ZB.MOM.WW.ScadaBridge.ManagementService;
/// <summary>
/// <c>GET /api/audit/export?format=parquet</c> (ScadaBridge#42): the same 24 columns as the
/// csv / jsonl export (<see cref="AuditExportRow"/>, in <see cref="AuditEndpoints.CsvHeader"/>
/// order) as one Parquet file, one row group per <see cref="RowGroupRows"/> rows.
/// </summary>
/// <remarks>
/// <para>
/// Types: ids are <c>D</c>-format strings, timestamps are UTC Parquet timestamps
/// (microseconds), enums are their names, <c>HttpStatus</c>/<c>DurationMs</c> are int32,
/// <c>PayloadTruncated</c> is boolean, everything else is a UTF-8 string.
/// </para>
/// <para>
/// <b>Streaming.</b> Kestrel forbids synchronous writes to the response body and the Parquet
/// writer issues some, so each row group is encoded into a small in-memory buffer and then
/// copied to the response asynchronously. Memory is bounded by one row group, and the client
/// starts receiving bytes after the first one. A Parquet file's footer is written last, so a
/// response cut off mid-stream is not a readable file — the CLI treats it as a failed export.
/// </para>
/// <para>
/// This is the ad-hoc export. The retention archive (alog.md §15.2) is a different layout
/// that carries the hash-chain columns; see <c>AuditArchiveParquet</c>.
/// </para>
/// </remarks>
public static class AuditExportParquet
{
/// <summary>Rows per Parquet row group.</summary>
public const int RowGroupRows = 10_000;
private static readonly DataField<string> EventId = new(nameof(AuditExportRow.EventId), false);
private static readonly DateTimeDataField OccurredAtUtc = Timestamp(nameof(AuditExportRow.OccurredAtUtc), false);
private static readonly DateTimeDataField IngestedAtUtc = Timestamp(nameof(AuditExportRow.IngestedAtUtc), true);
private static readonly DataField<string> Channel = new(nameof(AuditExportRow.Channel), false);
private static readonly DataField<string> Kind = new(nameof(AuditExportRow.Kind), false);
private static readonly DataField<string> CorrelationId = Text(nameof(AuditExportRow.CorrelationId));
private static readonly DataField<string> ExecutionId = Text(nameof(AuditExportRow.ExecutionId));
private static readonly DataField<string> ParentExecutionId = Text(nameof(AuditExportRow.ParentExecutionId));
private static readonly DataField<string> SourceSiteId = Text(nameof(AuditExportRow.SourceSiteId));
private static readonly DataField<string> SourceNode = Text(nameof(AuditExportRow.SourceNode));
private static readonly DataField<string> SourceInstanceId = Text(nameof(AuditExportRow.SourceInstanceId));
private static readonly DataField<string> SourceScript = Text(nameof(AuditExportRow.SourceScript));
private static readonly DataField<string> Actor = Text(nameof(AuditExportRow.Actor));
private static readonly DataField<string> Target = Text(nameof(AuditExportRow.Target));
private static readonly DataField<string> Status = new(nameof(AuditExportRow.Status), false);
private static readonly DataField<int> HttpStatus = new(nameof(AuditExportRow.HttpStatus), true);
private static readonly DataField<int> DurationMs = new(nameof(AuditExportRow.DurationMs), true);
private static readonly DataField<string> ErrorMessage = Text(nameof(AuditExportRow.ErrorMessage));
private static readonly DataField<string> ErrorDetail = Text(nameof(AuditExportRow.ErrorDetail));
private static readonly DataField<string> RequestSummary = Text(nameof(AuditExportRow.RequestSummary));
private static readonly DataField<string> ResponseSummary = Text(nameof(AuditExportRow.ResponseSummary));
private static readonly DataField<bool> PayloadTruncated = new(nameof(AuditExportRow.PayloadTruncated), false);
private static readonly DataField<string> Extra = Text(nameof(AuditExportRow.Extra));
private static readonly DataField<string> ForwardState = Text(nameof(AuditExportRow.ForwardState));
/// <summary>The export schema; column names and order equal <see cref="AuditEndpoints.CsvHeader"/>.</summary>
public static ParquetSchema Schema { get; } = new(
EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId, ExecutionId, ParentExecutionId,
SourceSiteId, SourceNode, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs,
ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState);
/// <summary>Writes every row of <paramref name="pages"/> to <paramref name="output"/> as one Parquet file.</summary>
/// <param name="pages">Export rows, page by page.</param>
/// <param name="output">The destination (e.g. the HTTP response body); written asynchronously only.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The number of rows written.</returns>
public static async Task<long> WriteAsync(
IAsyncEnumerable<IReadOnlyList<AuditExportRow>> pages, Stream output, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(pages);
ArgumentNullException.ThrowIfNull(output);
// Non-seekable on purpose: the writer then tracks file offsets itself, so emptying the
// buffer after each drain cannot corrupt the column-chunk offsets in the footer.
var staging = new StagingStream();
long total = 0;
var buffer = new List<AuditExportRow>(RowGroupRows);
await using (var writer = await ParquetWriter.CreateAsync(Schema, staging, cancellationToken: ct).ConfigureAwait(false))
{
await foreach (var page in pages.WithCancellation(ct).ConfigureAwait(false))
{
foreach (var row in page)
{
buffer.Add(row);
if (buffer.Count == RowGroupRows)
{
total += await WriteGroupAsync(writer, buffer, ct).ConfigureAwait(false);
await DrainAsync(staging, output, ct).ConfigureAwait(false);
}
}
}
total += await WriteGroupAsync(writer, buffer, ct).ConfigureAwait(false);
}
// The footer is written when the writer is disposed.
await DrainAsync(staging, output, ct).ConfigureAwait(false);
return total;
}
private static async Task<int> WriteGroupAsync(ParquetWriter writer, List<AuditExportRow> rows, CancellationToken ct)
{
if (rows.Count == 0)
{
return 0;
}
using (var group = writer.CreateRowGroup())
{
await group.WriteAsync(EventId, Map(rows, r => r.EventId.ToString("D"))).ConfigureAwait(false);
await group.WriteAsync<DateTime>(OccurredAtUtc, rows.Select(r => DateTime.SpecifyKind(r.OccurredAtUtc, DateTimeKind.Utc)).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync<DateTime>(IngestedAtUtc, rows.Select(r => r.IngestedAtUtc is { } i ? DateTime.SpecifyKind(i, DateTimeKind.Utc) : (DateTime?)null).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(Channel, Map(rows, r => r.Channel.ToString())).ConfigureAwait(false);
await group.WriteAsync(Kind, Map(rows, r => r.Kind.ToString())).ConfigureAwait(false);
await group.WriteAsync(CorrelationId, Map(rows, r => r.CorrelationId?.ToString("D"))!).ConfigureAwait(false);
await group.WriteAsync(ExecutionId, Map(rows, r => r.ExecutionId?.ToString("D"))!).ConfigureAwait(false);
await group.WriteAsync(ParentExecutionId, Map(rows, r => r.ParentExecutionId?.ToString("D"))!).ConfigureAwait(false);
await group.WriteAsync(SourceSiteId, Map(rows, r => r.SourceSiteId)!).ConfigureAwait(false);
await group.WriteAsync(SourceNode, Map(rows, r => r.SourceNode)!).ConfigureAwait(false);
await group.WriteAsync(SourceInstanceId, Map(rows, r => r.SourceInstanceId)!).ConfigureAwait(false);
await group.WriteAsync(SourceScript, Map(rows, r => r.SourceScript)!).ConfigureAwait(false);
await group.WriteAsync(Actor, Map(rows, r => r.Actor)!).ConfigureAwait(false);
await group.WriteAsync(Target, Map(rows, r => r.Target)!).ConfigureAwait(false);
await group.WriteAsync(Status, Map(rows, r => r.Status.ToString())).ConfigureAwait(false);
await group.WriteAsync<int>(HttpStatus, rows.Select(r => r.HttpStatus).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync<int>(DurationMs, rows.Select(r => r.DurationMs).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(ErrorMessage, Map(rows, r => r.ErrorMessage)!).ConfigureAwait(false);
await group.WriteAsync(ErrorDetail, Map(rows, r => r.ErrorDetail)!).ConfigureAwait(false);
await group.WriteAsync(RequestSummary, Map(rows, r => r.RequestSummary)!).ConfigureAwait(false);
await group.WriteAsync(ResponseSummary, Map(rows, r => r.ResponseSummary)!).ConfigureAwait(false);
await group.WriteAsync<bool>(PayloadTruncated, rows.Select(r => r.PayloadTruncated).ToArray(), cancellationToken: ct).ConfigureAwait(false);
await group.WriteAsync(Extra, Map(rows, r => r.Extra)!).ConfigureAwait(false);
await group.WriteAsync(ForwardState, Map(rows, r => r.ForwardState?.ToString())!).ConfigureAwait(false);
}
var count = rows.Count;
rows.Clear();
return count;
}
private static async Task DrainAsync(StagingStream staging, Stream output, CancellationToken ct)
{
var pending = staging.Pending;
if (pending.Length == 0)
{
return;
}
await output.WriteAsync(pending, ct).ConfigureAwait(false);
await output.FlushAsync(ct).ConfigureAwait(false);
staging.Clear();
}
private static string?[] Map(List<AuditExportRow> rows, Func<AuditExportRow, string?> select)
=> rows.Select(select).ToArray();
private static DataField<string> Text(string name) => new(name, true);
private static DateTimeDataField Timestamp(string name, bool nullable)
=> new(name, DateTimeFormat.Timestamp, isAdjustedToUTC: true, unit: DateTimeTimeUnit.Micros, isNullable: nullable);
/// <summary>
/// Forward-only, non-seekable buffer the Parquet writer writes into (synchronously or
/// not); <see cref="DrainAsync"/> moves its bytes to the real output asynchronously.
/// </summary>
private sealed class StagingStream : Stream
{
private readonly MemoryStream _buffer = new();
public ReadOnlyMemory<byte> Pending => _buffer.GetBuffer().AsMemory(0, (int)_buffer.Length);
public override bool CanRead => false;
public override bool CanSeek => false;
public override bool CanWrite => true;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => throw new NotSupportedException();
set => throw new NotSupportedException();
}
public void Clear() => _buffer.SetLength(0);
public override void Write(byte[] buffer, int offset, int count) => _buffer.Write(buffer, offset, count);
public override void Write(ReadOnlySpan<byte> buffer) => _buffer.Write(buffer);
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{
_buffer.Write(buffer.Span);
return ValueTask.CompletedTask;
}
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
_buffer.Write(buffer, offset, count);
return Task.CompletedTask;
}
public override void Flush()
{
}
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
}
}