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:
@@ -45,7 +45,7 @@ public static class AuditChainLock
|
||||
/// those two columns; the ingest writer role keeps its table-wide DENY UPDATE.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AuditLogHashChain : IAuditLogHashChain
|
||||
public sealed class AuditLogHashChain : IAuditLogHashChain, IAuditChainHeadStore
|
||||
{
|
||||
// 4 parameters per row; 200 rows = 800 parameters, far under SQL Server's 2,100 cap.
|
||||
private const int UpdateChunkRows = 200;
|
||||
@@ -273,6 +273,151 @@ public sealed class AuditLogHashChain : IAuditLogHashChain
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<AuditChainHeadSnapshot>> ReadHeadsAsync(CancellationToken ct = default)
|
||||
{
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
|
||||
await AcquireSharedOrThrowAsync(conn, tx, ct).ConfigureAwait(false);
|
||||
var heads = await ReadHeadsCoreAsync(conn, tx, ct).ConfigureAwait(false);
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
return heads;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuditChainHeadVerificationResult> VerifyExportedHeadsAsync(
|
||||
IReadOnlyList<AuditChainHeadRecord> records, CancellationToken ct = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(records);
|
||||
|
||||
// Collapse duplicates (both centrals export, and every unchanged re-export repeats a
|
||||
// record): one check per distinct (month, sequence, hash) claim.
|
||||
var distinct = records
|
||||
.GroupBy(r => (r.Month, r.LastSeq, Hash: r.LastHash?.ToLowerInvariant()))
|
||||
.Select(g => g.OrderByDescending(r => r.ExportedAtUtc).First())
|
||||
.ToList();
|
||||
|
||||
var conn = _context.Database.GetDbConnection();
|
||||
var openedHere = await OpenAsync(conn, ct).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await using var tx = await conn.BeginTransactionAsync(IsolationLevel.ReadCommitted, ct).ConfigureAwait(false);
|
||||
await AcquireSharedOrThrowAsync(conn, tx, ct).ConfigureAwait(false);
|
||||
var heads = (await ReadHeadsCoreAsync(conn, tx, ct).ConfigureAwait(false))
|
||||
.ToDictionary(h => AuditChainHasher.PartitionLabel(h.Month), StringComparer.Ordinal);
|
||||
|
||||
var checks = new List<AuditChainHeadCheck>(distinct.Count);
|
||||
foreach (var record in distinct)
|
||||
{
|
||||
if (!AuditChainHeadComparer.TryParseMonth(record.Month, out var month))
|
||||
{
|
||||
checks.Add(new AuditChainHeadCheck(record.Month ?? "", record.LastSeq, record.ExportedAtUtc, record.Node,
|
||||
AuditChainHeadStatus.Mismatch, null, $"Exported month '{record.Month}' is not YYYY-MM."));
|
||||
continue;
|
||||
}
|
||||
|
||||
heads.TryGetValue(AuditChainHasher.PartitionLabel(month), out var head);
|
||||
checks.Add(await AuditChainHeadComparer.CompareAsync(
|
||||
record, head, seq => ReadStoredLinkAsync(conn, tx, month, seq, ct)).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync(ct).ConfigureAwait(false);
|
||||
var ordered = checks
|
||||
.OrderByDescending(c => c.IsFailure)
|
||||
.ThenBy(c => c.Month, StringComparer.Ordinal)
|
||||
.ThenBy(c => c.ExportedSeq)
|
||||
.ToList();
|
||||
return new AuditChainHeadVerificationResult(ordered.All(c => !c.IsFailure), ordered.Count, ordered);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (openedHere)
|
||||
{
|
||||
await conn.CloseAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AcquireSharedOrThrowAsync(DbConnection conn, DbTransaction tx, CancellationToken ct)
|
||||
{
|
||||
if (!await TryAcquireLockAsync(conn, tx, "Shared", VerifyLockTimeout, ct).ConfigureAwait(false))
|
||||
{
|
||||
await tx.RollbackAsync(ct).ConfigureAwait(false);
|
||||
throw new TimeoutException(
|
||||
$"The AuditLog hash chain was busy (chaining or purge in progress) for {VerifyLockTimeout.TotalSeconds:0}s; retry.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<AuditChainHeadSnapshot>> ReadHeadsCoreAsync(DbConnection conn, DbTransaction tx, CancellationToken ct)
|
||||
{
|
||||
// Per month: the head row, the chained rows still present (the filtered ChainSeq index,
|
||||
// partition-eliminated by the month range) and the tombstones. The head table holds one
|
||||
// row per month ever chained, so this is a few dozen rows.
|
||||
var heads = new List<AuditChainHeadSnapshot>();
|
||||
await using var cmd = Command(conn, tx, @"
|
||||
SELECT h.ChainMonth, h.LastSeq, h.LastHash, h.PurgedThroughSeq, h.PurgedThroughHash, h.UpdatedAtUtc,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.AuditLog a
|
||||
WHERE a.ChainSeq IS NOT NULL
|
||||
AND a.OccurredAtUtc >= CAST(h.ChainMonth AS datetime2(7))
|
||||
AND a.OccurredAtUtc < DATEADD(MONTH, 1, CAST(h.ChainMonth AS datetime2(7)))) AS ChainedRows,
|
||||
(SELECT COUNT_BIG(*) FROM dbo.AuditLogChainTombstone t WHERE t.ChainMonth = h.ChainMonth) AS Tombstones
|
||||
FROM dbo.AuditLogChainHead h
|
||||
ORDER BY h.ChainMonth;");
|
||||
cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
heads.Add(new AuditChainHeadSnapshot(
|
||||
DateTime.SpecifyKind(reader.GetDateTime(0), DateTimeKind.Utc),
|
||||
reader.GetInt64(1),
|
||||
(byte[])reader.GetValue(2),
|
||||
reader.IsDBNull(3) ? null : reader.GetInt64(3),
|
||||
reader.IsDBNull(4) ? null : (byte[])reader.GetValue(4),
|
||||
reader.GetInt64(6),
|
||||
reader.GetInt64(7),
|
||||
DateTime.SpecifyKind(reader.GetDateTime(5), DateTimeKind.Utc)));
|
||||
}
|
||||
|
||||
return heads;
|
||||
}
|
||||
|
||||
private async Task<AuditChainHeadComparer.StoredLink> ReadStoredLinkAsync(
|
||||
DbConnection conn, DbTransaction tx, DateTime month, long seq, CancellationToken ct)
|
||||
{
|
||||
byte[]? rowHash;
|
||||
await using (var cmd = Command(conn, tx,
|
||||
"SELECT TOP (1) RowHash FROM dbo.AuditLog WHERE ChainSeq = @seq AND OccurredAtUtc >= @monthStart AND OccurredAtUtc < @monthEnd;"))
|
||||
{
|
||||
cmd.CommandTimeout = VerifyCommandTimeoutSeconds;
|
||||
cmd.Parameters.Add(new SqlParameter("@seq", SqlDbType.BigInt) { Value = seq });
|
||||
AddMonthRange(cmd, month);
|
||||
rowHash = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) as byte[];
|
||||
}
|
||||
|
||||
byte[]? tombstoneHash = null;
|
||||
if (rowHash is null)
|
||||
{
|
||||
await using var cmd = Command(conn, tx,
|
||||
"SELECT RowHash FROM dbo.AuditLogChainTombstone WHERE ChainMonth = @month AND ChainSeq = @seq;");
|
||||
AddDate(cmd, "@month", month);
|
||||
cmd.Parameters.Add(new SqlParameter("@seq", SqlDbType.BigInt) { Value = seq });
|
||||
tombstoneHash = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false) as byte[];
|
||||
}
|
||||
|
||||
return new AuditChainHeadComparer.StoredLink(rowHash, tombstoneHash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the chain application lock inside <paramref name="tx"/> (released at commit /
|
||||
/// rollback). Returns false when the lock could not be granted within <paramref name="timeout"/>.
|
||||
|
||||
Reference in New Issue
Block a user