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
201 lines
9.2 KiB
C#
201 lines
9.2 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));
|
|
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());
|
|
}
|