feat(management): any-of role gates; restrict ListSecuredWrites to Operator/Verifier/Admin (arch-review UA1)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:03:51 -04:00
parent 4fb754f9f6
commit 6c9de9f7ab
8 changed files with 133 additions and 18 deletions
@@ -6,6 +6,7 @@ using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
@@ -86,6 +87,39 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Contains("Administrator", response.Message);
}
// ========================================================================
// ListSecuredWrites role gate (arch-review UA1): read-only, but the
// history table exposes process-sensitive tag values, so it is restricted
// to the two-person Secured Write roles + Administrator (any-of).
// ========================================================================
[Fact]
public void ListSecuredWrites_ViewerOnly_Unauthorized()
{
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
[Theory]
[InlineData("Operator")]
[InlineData("Verifier")]
[InlineData("Administrator")]
public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
{
var securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
securedWriteRepo.QueryAsync(
Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<PendingSecuredWrite>>(
Array.Empty<PendingSecuredWrite>()));
_services.AddScoped(_ => securedWriteRepo);
var actor = CreateActor();
actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
}
[Fact]
public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
{
@@ -352,7 +352,10 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
});
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Viewer");
// arch-review UA1: ListSecuredWrites is gated to the Secured Write
// roles + Administrator, so use Operator here (this test asserts filter
// forwarding, not the role gate — see ListSecuredWrites_* auth tests).
var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Operator");
actor.Tell(envelope);
@@ -370,7 +373,8 @@ public class SecuredWriteHandlerTests : TestKit, IDisposable
.Returns(new List<PendingSecuredWrite>());
var actor = CreateActor();
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol");
// arch-review UA1: gated read — Operator satisfies the any-of gate.
var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator");
actor.Tell(envelope);