Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Maintenance/AuditMaintenanceLoginTests.cs
T
Joseph Doherty 52da02a76a feat(audit): hash-chain cutoff - chain only rows written from first enablement, no backlog (#139)
Owner decision on #107 (2026-11-13): enabling AuditLog:HashChain must not
chain the historical backlog. main chained every unchained row oldest-first.

- The first chaining pass on a pristine database (no head, no chained row)
  writes a chain-start anchor: AuditLogChainTombstone (cutoff month,
  ChainSeq 0), PurgedAtUtc = DB SYSUTCDATETIME(), RowHash =
  SHA-256("ScadaBridge.AuditLog.ChainCutoff.v1" || YYYY-MM || ticks).
  Written once under the exclusive chain lock, so both centrals agree
  whatever their clocks/config. No migration: existing table + grants.
- Only rows with OccurredAtUtc >= cutoff are chained. The anchor hash is
  the cutoff month's genesis, binding the cutoff into the chain.
- AuditLog:HashChain:ChainBacklog (default false) restores chain-everything,
  read only on the very first enablement. Legacy chains (heads, no anchor)
  keep chaining everything.
- verify-chain splits unchained rows into UnchainedRows vs PreCutoffRows
  (+ ChainFromUtc); new breaks ChainedBeforeCutoff (row re-dated before the
  cutoff, or cutoff moved forward) and CutoffAnchorInvalid (anchor hash /
  month / multiplicity). Archive verify uses the anchor from its own
  tombstone file or the DB.
- Partition switch-out keeps the anchor (deletes ChainSeq > 0 only); the
  head store / export / verify-heads tombstone count excludes it.
- CLI verify-chain table prints Chain from + Pre-cutoff rows.

Tests: AuditLogHashChainCutoffTests (7, MSSQL), 6 verifier unit tests,
service/CLI/management wire tests; 9 mutations all killed.

Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
2026-11-13 03:14:14 -05:00

250 lines
12 KiB
C#

using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Maintenance;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;
namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Maintenance;
/// <summary>
/// The hardened two-login setup (ScadaBridge#107 item 3), end to end on SQL Server: a writer
/// login (<c>db_datareader</c> + <c>db_datawriter</c> + <c>scadabridge_audit_writer</c>) and a
/// maintenance login (<c>scadabridge_audit_chainer</c> + <c>scadabridge_audit_purger</c> only),
/// wired through <c>AddConfigurationDatabase(writer, maintenance)</c>. The writer ingests but
/// cannot chain, update or delete; the maintenance services run on the maintenance login and
/// can chain, verify, export heads, per-channel purge, enumerate partitions, archive and switch.
/// </summary>
public sealed class AuditMaintenanceLoginTests : IClassFixture<MsSqlMigrationFixture>, IAsyncLifetime
{
private const string Password = "Sb_W22_maint#2026";
private readonly MsSqlMigrationFixture _fixture;
private readonly string _suffix = Guid.NewGuid().ToString("N")[..10];
private readonly DirectoryInfo _archiveDir = Directory.CreateTempSubdirectory("maint-login-archive-");
/// <summary>Creates the test class with its own migrated SQL Server database.</summary>
/// <param name="fixture">The per-class migration fixture.</param>
public AuditMaintenanceLoginTests(MsSqlMigrationFixture fixture) => _fixture = fixture;
private string WriterLogin => "sbw_" + _suffix;
private string MaintenanceLogin => "sbm_" + _suffix;
/// <inheritdoc />
public async Task InitializeAsync()
{
if (!_fixture.Available)
{
return;
}
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand($@"
CREATE LOGIN [{WriterLogin}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
CREATE LOGIN [{MaintenanceLogin}] WITH PASSWORD = N'{Password}', CHECK_POLICY = OFF;
CREATE USER [{WriterLogin}] FOR LOGIN [{WriterLogin}];
CREATE USER [{MaintenanceLogin}] FOR LOGIN [{MaintenanceLogin}];
ALTER ROLE db_datareader ADD MEMBER [{WriterLogin}];
ALTER ROLE db_datawriter ADD MEMBER [{WriterLogin}];
ALTER ROLE scadabridge_audit_writer ADD MEMBER [{WriterLogin}];
ALTER ROLE scadabridge_audit_chainer ADD MEMBER [{MaintenanceLogin}];
ALTER ROLE scadabridge_audit_purger ADD MEMBER [{MaintenanceLogin}];", conn);
await cmd.ExecuteNonQueryAsync();
}
/// <inheritdoc />
public async Task DisposeAsync()
{
_archiveDir.Delete(recursive: true);
if (!_fixture.Available)
{
return;
}
SqlConnection.ClearAllPools();
await using var conn = _fixture.OpenConnection();
await using var cmd = new SqlCommand($@"
DECLARE @kill nvarchar(max) = N'';
SELECT @kill = @kill + N'KILL ' + CAST(session_id AS nvarchar(10)) + N';' FROM sys.dm_exec_sessions
WHERE login_name IN (N'{WriterLogin}', N'{MaintenanceLogin}');
EXEC sp_executesql @kill;
IF USER_ID(N'{WriterLogin}') IS NOT NULL DROP USER [{WriterLogin}];
IF USER_ID(N'{MaintenanceLogin}') IS NOT NULL DROP USER [{MaintenanceLogin}];
IF SUSER_ID(N'{WriterLogin}') IS NOT NULL DROP LOGIN [{WriterLogin}];
IF SUSER_ID(N'{MaintenanceLogin}') IS NOT NULL DROP LOGIN [{MaintenanceLogin}];", conn);
await cmd.ExecuteNonQueryAsync();
}
/// <summary>
/// Writer ingests and is refused every maintenance write; the maintenance services, resolved
/// from DI on the separate login, do the whole chain + purge + archive cycle.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task HardenedTwoLoginSetup_WriterIngestsOnly_MaintenanceLoginRunsChainAndPurges()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var writerCs = As(WriterLogin);
var maintenanceCs = As(MaintenanceLogin);
var month = new DateTime(2027, 2, 1, 0, 0, 0, DateTimeKind.Utc);
// The writer login ingests.
await using (var writerCtx = Context(writerCs))
{
var repo = new AuditLogRepository(writerCtx);
foreach (var i in Enumerable.Range(1, 4))
{
await repo.InsertIfNotExistsAsync(NewEvent(month.AddDays(i), AuditChannel.ApiOutbound));
}
await repo.InsertIfNotExistsAsync(NewEvent(month.AddHours(2), AuditChannel.DbOutbound));
}
// Negative controls: the writer login can neither chain nor delete.
await using (var writerCtx = Context(writerCs))
{
await Assert.ThrowsAsync<SqlException>(() => new AuditLogHashChain(writerCtx).ChainPendingAsync(100, chainBacklog: true));
await Assert.ThrowsAsync<SqlException>(() =>
new AuditLogRepository(writerCtx).PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100));
}
var services = new ServiceCollection();
services.AddLogging();
services.AddConfigurationDatabase(writerCs, maintenanceCs);
services.AddSingleton(Options.Create(new AuditLogArchiveOptions
{
Enabled = true,
Directory = _archiveDir.FullName,
RowGroupSize = 1_000,
}));
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var maintenance = scope.ServiceProvider.GetRequiredService<AuditMaintenanceContext>();
Assert.True(maintenance.UsesSeparateLogin);
Assert.Equal(MaintenanceLogin, await LoginNameAsync(maintenance.Context));
Assert.Equal(WriterLogin, await LoginNameAsync(scope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>()));
var chain = scope.ServiceProvider.GetRequiredService<IAuditLogHashChain>();
Assert.Equal(5, (await chain.ChainPendingAsync(100, chainBacklog: true)).RowsChained);
Assert.Equal(AuditChainStatus.Intact, (await chain.VerifyMonthAsync(month)).Status);
var head = Assert.Single(await scope.ServiceProvider.GetRequiredService<IAuditChainHeadStore>().ReadHeadsAsync());
Assert.Equal(5, head.LastSeq);
var repo = scope.ServiceProvider.GetRequiredKeyedService<IAuditLogRepository>(AuditMaintenanceContext.ServiceKey);
Assert.Equal(1, await repo.PurgeChannelOlderThanAsync("DbOutbound", month.AddDays(1), 100));
Assert.Contains(month, await repo.GetPartitionBoundariesOlderThanAsync(month.AddYears(5)));
var archive = scope.ServiceProvider.GetRequiredKeyedService<IAuditLogArchive>(AuditMaintenanceContext.ServiceKey);
var manifest = await archive.ArchivePartitionAsync(month);
Assert.Equal(4, manifest.RowCount);
Assert.Equal(4, await archive.PurgeArchivedPartitionAsync(manifest));
Assert.Equal(AuditChainStatus.Intact, (await chain.VerifyMonthAsync(month)).Status);
}
// Unset maintenance string: the maintenance context is the runtime context, as before.
var plain = new ServiceCollection();
plain.AddLogging();
plain.AddConfigurationDatabase(writerCs);
await using var plainProvider = plain.BuildServiceProvider();
await using var plainScope = plainProvider.CreateAsyncScope();
var shared = plainScope.ServiceProvider.GetRequiredService<AuditMaintenanceContext>();
Assert.False(shared.UsesSeparateLogin);
Assert.Same(plainScope.ServiceProvider.GetRequiredService<ScadaBridgeDbContext>(), shared.Context);
}
/// <summary>
/// ScadaBridge#113: the monthly roll-forward (<c>ALTER PARTITION FUNCTION … SPLIT RANGE</c>) and
/// the <c>SourceNode</c> backfill run on the maintenance login — and are refused to the writer
/// login — with only the role grants the <c>AuditMaintenanceGrants</c> migration provisions.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task RollForwardAndSourceNodeBackfill_RunOnTheMaintenanceLogin_NotTheWriter()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var writerCs = As(WriterLogin);
var maintenanceCs = As(MaintenanceLogin);
var month = new DateTime(2027, 4, 1, 0, 0, 0, DateTimeKind.Utc);
// A pre-feature row: no SourceNode, not chained.
await using (var writerCtx = Context(writerCs))
{
var legacy = NewEvent(month.AddDays(1), AuditChannel.ApiOutbound) with { SourceNode = null };
await new AuditLogRepository(writerCtx).InsertIfNotExistsAsync(legacy);
}
// Negative controls: the writer login can neither split the partition function nor backfill.
await using (var writerCtx = Context(writerCs))
{
await Assert.ThrowsAsync<SqlException>(() => new AuditLogPartitionMaintenance(writerCtx).EnsureLookaheadAsync(60));
await Assert.ThrowsAsync<SqlException>(() =>
new AuditLogRepository(writerCtx).BackfillSourceNodeAsync("unknown", month.AddYears(1), 100));
}
var services = new ServiceCollection();
services.AddLogging();
services.AddConfigurationDatabase(writerCs, maintenanceCs);
await using var provider = services.BuildServiceProvider();
await using (var scope = provider.CreateAsyncScope())
{
var maintenance = scope.ServiceProvider.GetRequiredService<AuditMaintenanceContext>();
var roll = scope.ServiceProvider.GetRequiredService<ZB.MOM.WW.ScadaBridge.Commons.Interfaces.IPartitionMaintenance>();
var before = await roll.GetMaxBoundaryAsync();
var added = await roll.EnsureLookaheadAsync(60);
Assert.NotEmpty(added);
Assert.True(await roll.GetMaxBoundaryAsync() > before);
var repo = scope.ServiceProvider.GetRequiredKeyedService<IAuditLogRepository>(
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories.AuditLogServiceKeys.Maintenance);
Assert.Equal(1, await repo.BackfillSourceNodeAsync("unknown", month.AddYears(1), 100));
Assert.Equal(MaintenanceLogin, await LoginNameAsync(maintenance.Context));
}
}
private string As(string login) => new SqlConnectionStringBuilder(_fixture.ConnectionString)
{
UserID = login,
Password = Password,
IntegratedSecurity = false,
}.ConnectionString;
private static ScadaBridgeDbContext Context(string cs)
=> new(new DbContextOptionsBuilder<ScadaBridgeDbContext>().UseSqlServer(cs).Options);
private static async Task<string> LoginNameAsync(ScadaBridgeDbContext ctx)
{
var conn = ctx.Database.GetDbConnection();
await conn.OpenAsync();
try
{
await using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT SUSER_SNAME();";
return (string)(await cmd.ExecuteScalarAsync())!;
}
finally
{
await conn.CloseAsync();
}
}
private static AuditEvent NewEvent(DateTime occurredAtUtc, AuditChannel channel) =>
ScadaBridgeAuditEventFactory.Create(
channel: channel,
kind: channel == AuditChannel.DbOutbound ? AuditKind.DbWrite : AuditKind.ApiCall,
status: AuditStatus.Delivered,
occurredAtUtc: occurredAtUtc,
actor: "maint-login-test",
target: "erp",
sourceNode: "central-a",
sourceSiteId: "maint-login-test",
correlationId: Guid.NewGuid());
}