feat(audit): off-box chain-head export, verify-heads, maintenance login (ScadaBridge#107)
Item 1 (owner decision: no signing). AuditLog:HashChain:HeadExport (default
off): both centrals periodically read every month's chain head (LastSeq,
LastHash, purge anchor, chained rows, tombstones; one snapshot under the
chain lock SHARED) and append the changed heads to a sink the DB login cannot
write - File (append-only JSONL per node per day, local or UNC) or Syslog
(RFC 5424 over UDP / TCP octet counting). `audit verify-heads --path` reads
those records back (file or syslog-collector lines) and the new
VerifyAuditChainHeads command checks each against the database: Matches /
Extended / Purged, or Mismatch / Regressed / MissingHead / MissingLink /
CountMismatch (exit 3). AuditChainHeadStoreTests forges a chain with the
production hasher: verify-chain says Intact, verify-heads says Mismatch.
Item 3. AuditLog:MaintenanceConnectionString (${secret:}-capable, unset =
today's single login) runs the chain job, chain verify / head export, the
partition switch-out + archive and the per-channel purge on
AuditMaintenanceContext (keyed audit-maintenance repository/archive, purge
actor prefers them). AuditMaintenanceLoginTests runs the hardened setup on
SQL Server: writer (datareader/datawriter/audit_writer) ingests and is
refused chaining and deleting; the chainer+purger login does the full cycle.
Two-login grants documented in Component-AuditLog.md.
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
@@ -392,6 +392,8 @@ public class ManagementActor : ReceiveActor
|
||||
// permission). A site-scoped caller is refused in the handler: a month's chain
|
||||
// spans every site.
|
||||
VerifyAuditChainCommand => OperationalAuditReaders,
|
||||
// Off-box chain-head check (ScadaBridge#107 item 1): the same audit read.
|
||||
VerifyAuditChainHeadsCommand => OperationalAuditReaders,
|
||||
|
||||
// AuditLog Parquet archives (ScadaBridge#42): audit reads with verify-chain's gate —
|
||||
// OperationalAudit roles, site-scoped callers refused in the handler.
|
||||
@@ -569,6 +571,7 @@ public class ManagementActor : ReceiveActor
|
||||
// Audit Log
|
||||
QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),
|
||||
VerifyAuditChainCommand cmd => await HandleVerifyAuditChain(sp, cmd, user),
|
||||
VerifyAuditChainHeadsCommand cmd => await HandleVerifyAuditChainHeads(sp, cmd, user),
|
||||
ListAuditArchivesCommand => await HandleListAuditArchives(sp, user),
|
||||
VerifyAuditArchiveCommand cmd => await HandleVerifyAuditArchive(sp, cmd, user),
|
||||
|
||||
@@ -3273,6 +3276,48 @@ public class ManagementActor : ReceiveActor
|
||||
return new AuditArchiveListResult(archive.IsEnabled, await archive.ListAsync());
|
||||
}
|
||||
|
||||
/// <summary>Upper bound on records per <see cref="VerifyAuditChainHeadsCommand"/>.</summary>
|
||||
internal const int MaxChainHeadRecords = 50_000;
|
||||
|
||||
/// <summary>
|
||||
/// Checks exported AuditLog chain heads against the database (ScadaBridge#107 item 1). Same
|
||||
/// gate as verify-chain: a site-scoped, non-Administrator caller is refused because the
|
||||
/// chain spans every site.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleVerifyAuditChainHeads(
|
||||
IServiceProvider sp, VerifyAuditChainHeadsCommand cmd, AuthenticatedUser user)
|
||||
{
|
||||
if (cmd.Records is null || cmd.Records.Count == 0)
|
||||
{
|
||||
throw new ManagementCommandException("No exported chain-head records were supplied.", "INVALID_ARGUMENT");
|
||||
}
|
||||
|
||||
if (cmd.Records.Count > MaxChainHeadRecords)
|
||||
{
|
||||
throw new ManagementCommandException(
|
||||
$"{cmd.Records.Count} records exceed the {MaxChainHeadRecords} limit; verify fewer export files at a time.",
|
||||
"INVALID_ARGUMENT");
|
||||
}
|
||||
|
||||
if (user.PermittedSiteIds.Length > 0
|
||||
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new SiteScopeViolationException(
|
||||
"Access denied: the audit hash chain spans every site; verify-heads needs an "
|
||||
+ "OperationalAudit role that is not scoped to specific sites.");
|
||||
}
|
||||
|
||||
var store = sp.GetRequiredService<IAuditChainHeadStore>();
|
||||
try
|
||||
{
|
||||
return await store.VerifyExportedHeadsAsync(cmd.Records);
|
||||
}
|
||||
catch (TimeoutException ex)
|
||||
{
|
||||
throw new ManagementCommandException(ex.Message, "CHAIN_BUSY");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
|
||||
Reference in New Issue
Block a user