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:
Joseph Doherty
2026-11-09 23:16:05 -05:00
parent 5e7d5389d4
commit c4d7ec9b96
40 changed files with 2769 additions and 43 deletions
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
/// <summary>
/// The connection the AuditLog maintenance paths run on — the hash-chain job and both purge
/// paths (partition switch-out with its archive, per-channel DELETE) — ScadaBridge#107 item 3.
/// </summary>
/// <remarks>
/// <para>
/// With <c>AuditLog:MaintenanceConnectionString</c> unset (the default), this is the scope's
/// ordinary <see cref="ScadaBridgeDbContext"/>: one login for everything, exactly as before.
/// With it set, the maintenance paths get their OWN context on that connection string, so a
/// DBA can give the runtime (writer) login only <c>scadabridge_audit_writer</c> — whose DENYs
/// stop it writing chain state or deleting audit rows — and a separate maintenance login the
/// <c>scadabridge_audit_chainer</c> + <c>scadabridge_audit_purger</c> roles. The string is read
/// after the pre-host <c>${secret:}</c> expansion, so it may (and in production should) be a
/// secret reference.
/// </para>
/// <para>Scoped: an owned context is disposed with the scope; a borrowed one is left to DI.</para>
/// </remarks>
public sealed class AuditMaintenanceContext : IAsyncDisposable, IDisposable
{
/// <summary>DI key of the maintenance-login <c>IAuditLogRepository</c> and <c>IAuditLogArchive</c>.</summary>
public const string ServiceKey = "audit-maintenance";
private readonly bool _owned;
private AuditMaintenanceContext(ScadaBridgeDbContext context, bool owned)
{
Context = context;
_owned = owned;
}
/// <summary>The context to run maintenance SQL on.</summary>
public ScadaBridgeDbContext Context { get; }
/// <summary>True when <see cref="Context"/> is on the separate maintenance connection string.</summary>
public bool UsesSeparateLogin => _owned;
/// <summary>Wraps the scope's ordinary context (no separate maintenance login configured).</summary>
/// <param name="context">The scope's context.</param>
/// <returns>The wrapper.</returns>
public static AuditMaintenanceContext Shared(ScadaBridgeDbContext context)
=> new(context ?? throw new ArgumentNullException(nameof(context)), owned: false);
/// <summary>Opens an owned context on the maintenance connection string.</summary>
/// <param name="connectionString">The maintenance connection string.</param>
/// <param name="protectionProvider">Data protection for secret-bearing columns (same as the runtime context).</param>
/// <returns>The wrapper; disposing it disposes the context.</returns>
public static AuditMaintenanceContext Separate(string connectionString, IDataProtectionProvider? protectionProvider)
{
ArgumentException.ThrowIfNullOrWhiteSpace(connectionString);
var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(
maxRetryCount: 5, maxRetryDelay: TimeSpan.FromSeconds(30), errorNumbersToAdd: null))
.ConfigureWarnings(w => w.Ignore(
Microsoft.EntityFrameworkCore.Diagnostics.RelationalEventId.PendingModelChangesWarning))
.Options;
var context = protectionProvider is null
? new ScadaBridgeDbContext(options)
: new ScadaBridgeDbContext(options, protectionProvider);
return new AuditMaintenanceContext(context, owned: true);
}
/// <inheritdoc />
public ValueTask DisposeAsync() => _owned ? Context.DisposeAsync() : ValueTask.CompletedTask;
/// <inheritdoc />
public void Dispose()
{
if (_owned)
{
Context.Dispose();
}
}
}
/// <summary>The configured maintenance connection string (null = use the runtime connection).</summary>
/// <param name="ConnectionString">The connection string, or null.</param>
public sealed record AuditMaintenanceConnection(string? ConnectionString);