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
@@ -506,21 +506,43 @@ public class AuditEndpointsTests
Assert.Equal(propertyNames, AuditEndpoints.CsvHeader.Split(','));
}
/// <summary>Verifies that a Parquet export returns 501.</summary>
/// <summary>
/// <c>format=parquet</c> (ScadaBridge#42; it used to be a 501) streams one readable Parquet
/// file whose columns are the csv/jsonl export's columns, in order, holding every row.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Export_Parquet_Returns501()
public async Task Export_Parquet_StreamsReadableParquet_WithTheCsvColumns()
{
// Parquet archival is deferred to v1.x (Component-AuditLog.md) — no
// library is referenced, so the endpoint returns 501 with guidance.
var (client, _, host) = await BuildHostAsync(roles: new[] { "Administrator" });
var (client, _, host) = await BuildHostAsync(
roles: new[] { "Administrator" },
queryPages: new[]
{
(IReadOnlyList<AuditEvent>)new[]
{
SampleEvent(Guid.Parse("cccccccc-0000-0000-0000-000000000001")),
SampleEvent(Guid.Parse("cccccccc-0000-0000-0000-000000000002")),
},
(IReadOnlyList<AuditEvent>)Array.Empty<AuditEvent>(),
});
using (host)
{
var response = await client.SendAsync(Get("/api/audit/export?format=parquet"));
var response = await client.SendAsync(Get("/api/audit/export?format=Parquet"));
Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
var body = await response.Content.ReadAsStringAsync();
Assert.Contains("Parquet export deferred to v1.x", body);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(AuditEndpoints.ParquetContentType, response.Content.Headers.ContentType!.MediaType);
Assert.Contains(".parquet", response.Content.Headers.ContentDisposition?.ToString()
?? response.Headers.GetValues("Content-Disposition").FirstOrDefault()
?? response.Content.Headers.GetValues("Content-Disposition").First());
var bytes = await response.Content.ReadAsByteArrayAsync();
await using var reader = await Parquet.ParquetReader.CreateAsync(new MemoryStream(bytes));
Assert.Equal(AuditEndpoints.CsvHeader.Split(','), reader.Schema.GetDataFields().Select(f => f.Name));
using var group = reader.OpenRowGroupReader(0);
Assert.Equal(2, group.RowCount);
var ids = new string?[2];
await group.ReadAsync(reader.Schema.GetDataFields()[0], ids!);
Assert.Equal(new[] { "cccccccc-0000-0000-0000-000000000001", "cccccccc-0000-0000-0000-000000000002" }, ids);
}
}