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:
+82
@@ -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);
|
||||
Reference in New Issue
Block a user