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
@@ -393,6 +393,11 @@ public class ManagementActor : ReceiveActor
// spans every site.
VerifyAuditChainCommand => OperationalAuditReaders,
// AuditLog Parquet archives (ScadaBridge#42): audit reads with verify-chain's gate —
// OperationalAudit roles, site-scoped callers refused in the handler.
ListAuditArchivesCommand => OperationalAuditReaders,
VerifyAuditArchiveCommand => OperationalAuditReaders,
// Read-only queries -- any authenticated user
_ => null
};
@@ -564,6 +569,8 @@ public class ManagementActor : ReceiveActor
// Audit Log
QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),
VerifyAuditChainCommand cmd => await HandleVerifyAuditChain(sp, cmd, user),
ListAuditArchivesCommand => await HandleListAuditArchives(sp, user),
VerifyAuditArchiveCommand cmd => await HandleVerifyAuditArchive(sp, cmd, user),
// Health
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
@@ -3256,6 +3263,59 @@ public class ManagementActor : ReceiveActor
}
}
/// <summary>
/// Lists the AuditLog Parquet archives (ScadaBridge#42). Refused for a site-scoped,
/// non-Administrator caller: an archive holds every site's rows.
/// </summary>
private static async Task<object?> HandleListAuditArchives(IServiceProvider sp, AuthenticatedUser user)
{
var archive = RequireAuditArchive(sp, user);
return new AuditArchiveListResult(archive.IsEnabled, await archive.ListAsync());
}
/// <summary>
/// Verifies one AuditLog Parquet archive (ScadaBridge#42): files against the manifest,
/// the chain recomputed from the archived rows, the chain end against the purge anchor.
/// </summary>
private static async Task<object?> HandleVerifyAuditArchive(
IServiceProvider sp, VerifyAuditArchiveCommand cmd, AuthenticatedUser user)
{
var archive = RequireAuditArchive(sp, user);
try
{
return await archive.VerifyAsync(cmd.Name);
}
catch (ArgumentException ex)
{
throw new ManagementCommandException(ex.Message, "INVALID_ARGUMENT");
}
catch (FileNotFoundException ex)
{
throw new ManagementCommandException(ex.Message, "NOT_FOUND");
}
}
private static IAuditLogArchive RequireAuditArchive(IServiceProvider sp, AuthenticatedUser user)
{
if (user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
throw new SiteScopeViolationException(
"Access denied: AuditLog archives hold every site's rows; they need an "
+ "OperationalAudit role that is not scoped to specific sites.");
}
var archive = sp.GetService<IAuditLogArchive>();
if (archive is null || !archive.IsConfigured)
{
throw new ManagementCommandException(
"No AuditLog archive directory is configured on this central node (AuditLog:Archive:Directory).",
"ARCHIVE_NOT_CONFIGURED");
}
return archive;
}
// ========================================================================
// Health handlers
// ========================================================================