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:
+200
@@ -0,0 +1,200 @@
|
||||
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));
|
||||
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)).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);
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
Reference in New Issue
Block a user