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:
@@ -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
|
||||
// ========================================================================
|
||||
|
||||
Reference in New Issue
Block a user