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:
@@ -171,6 +171,8 @@ public class RequiredRoleMatrixTests
|
||||
// AuditLog hash-chain verification (ScadaBridge#41) carries audit query's
|
||||
// OperationalAudit permission (alog.md §15.1); written out by hand to keep the freeze.
|
||||
["VerifyAuditChain"] = [Roles.Administrator, Roles.Viewer],
|
||||
// Off-box exported chain heads (ScadaBridge#107): same audit-read gate.
|
||||
["VerifyAuditChainHeads"] = [Roles.Administrator, Roles.Viewer],
|
||||
// AuditLog Parquet archives (ScadaBridge#42): same audit-read gate as verify-chain.
|
||||
["ListAuditArchives"] = [Roles.Administrator, Roles.Viewer],
|
||||
["VerifyAuditArchive"] = [Roles.Administrator, Roles.Viewer],
|
||||
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
using System.Text.Json;
|
||||
using Akka.Actor;
|
||||
using Akka.TestKit.Xunit2;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The <c>VerifyAuditChainHeads</c> management command (ScadaBridge#107 item 1): role gate,
|
||||
/// site-scope refusal, input bounds, and the verdicts reaching the wire with enums as names.
|
||||
/// </summary>
|
||||
public class VerifyAuditChainHeadsCommandTests : TestKit
|
||||
{
|
||||
private readonly IAuditChainHeadStore _store = Substitute.For<IAuditChainHeadStore>();
|
||||
|
||||
private static readonly AuditChainHeadRecord Record = new(
|
||||
1, "2026-05", 12, new string('a', 64), null, null, 12, 0,
|
||||
new DateTime(2026, 5, 3, 0, 0, 0, DateTimeKind.Utc), new DateTime(2026, 5, 3, 1, 0, 0, DateTimeKind.Utc), "central-a");
|
||||
|
||||
private IActorRef CreateActor()
|
||||
{
|
||||
var services = new ServiceCollection().AddTestConfigurationTransaction();
|
||||
services.AddScoped(_ => _store);
|
||||
return Sys.ActorOf(Props.Create(() => new ManagementActor(
|
||||
services.BuildServiceProvider(), NullLogger<ManagementActor>.Instance)));
|
||||
}
|
||||
|
||||
private static ManagementEnvelope Envelope(object command, string[] sites, params string[] roles) =>
|
||||
new(new AuthenticatedUser("u", "U", roles, sites), command, Guid.NewGuid().ToString("N"));
|
||||
|
||||
/// <summary>A Viewer gets the store's verdicts; the records reach the store unchanged.</summary>
|
||||
[Fact]
|
||||
public void Viewer_GetsTheVerdicts_ForTheSuppliedRecords()
|
||||
{
|
||||
var check = new AuditChainHeadCheck("2026-05", 12, Record.ExportedAtUtc, "central-a",
|
||||
AuditChainHeadStatus.Mismatch, 12, "rewritten");
|
||||
_store.VerifyExportedHeadsAsync(Arg.Any<IReadOnlyList<AuditChainHeadRecord>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new AuditChainHeadVerificationResult(false, 1, [check]));
|
||||
|
||||
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Viewer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
using var json = JsonDocument.Parse(response.JsonData);
|
||||
Assert.False(json.RootElement.GetProperty("verified").GetBoolean());
|
||||
Assert.Equal("Mismatch", json.RootElement.GetProperty("checks")[0].GetProperty("status").GetString());
|
||||
_store.Received(1).VerifyExportedHeadsAsync(
|
||||
Arg.Is<IReadOnlyList<AuditChainHeadRecord>>(r => r.Count == 1 && r[0] == Record), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>A Designer/Deployer without an OperationalAudit role is refused.</summary>
|
||||
[Fact]
|
||||
public void NonAuditRole_IsUnauthorized()
|
||||
{
|
||||
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Designer", "Deployer"));
|
||||
|
||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>A site-scoped Viewer is refused: the chain spans every site.</summary>
|
||||
[Fact]
|
||||
public void SiteScopedViewer_IsRefused()
|
||||
{
|
||||
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), ["3"], "Viewer"));
|
||||
|
||||
var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
Assert.Contains("spans every site", response.Message);
|
||||
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>No records, or more than the cap, is a caller error.</summary>
|
||||
/// <param name="count">Records supplied.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(ManagementActor.MaxChainHeadRecords + 1)]
|
||||
public void EmptyOrOversizedInput_IsInvalidArgument(int count)
|
||||
{
|
||||
CreateActor().Tell(Envelope(
|
||||
new VerifyAuditChainHeadsCommand(Enumerable.Repeat(Record, count).ToList()), [], "Administrator"));
|
||||
|
||||
var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
|
||||
Assert.Equal("INVALID_ARGUMENT", response.ErrorCode);
|
||||
_store.DidNotReceiveWithAnyArgs().VerifyExportedHeadsAsync(default!, default);
|
||||
}
|
||||
|
||||
/// <summary>A chain lock that cannot be had surfaces as CHAIN_BUSY.</summary>
|
||||
[Fact]
|
||||
public void BusyChain_IsChainBusy()
|
||||
{
|
||||
_store.VerifyExportedHeadsAsync(Arg.Any<IReadOnlyList<AuditChainHeadRecord>>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<AuditChainHeadVerificationResult>>(_ => throw new TimeoutException("busy"));
|
||||
|
||||
CreateActor().Tell(Envelope(new VerifyAuditChainHeadsCommand([Record]), [], "Administrator"));
|
||||
|
||||
Assert.Equal("CHAIN_BUSY", ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5)).ErrorCode);
|
||||
}
|
||||
|
||||
/// <summary>Runs under the long-running Ask timeout.</summary>
|
||||
[Fact]
|
||||
public void VerifyAuditChainHeads_UsesLongRunningAskTimeout()
|
||||
{
|
||||
Assert.Equal(TimeSpan.FromMinutes(5),
|
||||
ManagementEndpoints.ResolveAskTimeout(new ManagementServiceOptions(), typeof(VerifyAuditChainHeadsCommand)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user