c4d7ec9b96
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
83 lines
3.9 KiB
C#
83 lines
3.9 KiB
C#
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);
|