diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs new file mode 100644 index 00000000..b5e1a04a --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/SecuredWriteCommands.cs @@ -0,0 +1,83 @@ +namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; + +// ============================================================================ +// Two-person ("secured") write commands (M7 OPC UA / MxGateway UX, Task T14b). +// +// An Operator SUBMITS a pending secured write against an MxGateway data +// connection; a distinct Verifier later APPROVES (executes — Task C3) or +// REJECTS it. Separation of duties is enforced at the handler: a write may not +// be verified by the same principal that submitted it. ListSecuredWrites is a +// read-only query (any authenticated user). Role gating lives in +// ManagementActor.GetRequiredRole (Operator for submit; Verifier for +// reject/approve). +// ============================================================================ + +/// +/// Operator request to submit a pending secured write. The target connection +/// must exist within the site and use the MxGateway protocol; the value is +/// captured as interpreted per . +/// Returns the newly-created . +/// +/// Site identifier the write targets. +/// Data connection name within the site. +/// Fully-qualified tag path the value is written to. +/// JSON-serialised value to write. +/// Target data type name (e.g. Boolean, Double). +/// Optional free-text comment supplied by the operator. +public record SubmitSecuredWriteCommand( + string SiteId, + string ConnectionName, + string TagPath, + string ValueJson, + string ValueType, + string? Comment); + +/// +/// Verifier request to approve (and execute — handled by Task C3) a pending +/// secured write. Declared here so the secured-write contract is complete; the +/// approve→execute relay handler and dispatch arm are implemented in C3. +/// +/// Identity of the pending secured write. +/// Optional free-text comment supplied by the verifier. +public record ApproveSecuredWriteCommand(long Id, string? Comment); + +/// +/// Verifier request to reject a pending secured write. The write must still be +/// Pending, and the verifier must differ from the submitting operator +/// (separation of duties). Returns the updated . +/// +/// Identity of the pending secured write. +/// Optional free-text comment supplied by the verifier. +public record RejectSecuredWriteCommand(long Id, string? Comment); + +/// +/// Read-only query for secured writes, optionally filtered by status and site. +/// A null filter matches every row. +/// +/// Status filter; null matches every status. +/// Site id filter; null matches every site. +public record ListSecuredWritesCommand(string? Status, string? SiteId); + +/// +/// Result projection of a single pending secured write row, mirroring the +/// PendingSecuredWrite entity shape. +/// +public record SecuredWriteDto( + long Id, + string SiteId, + string ConnectionName, + string TagPath, + string ValueJson, + string ValueType, + string Status, + string OperatorUser, + string? OperatorComment, + DateTime SubmittedAtUtc, + string? VerifierUser, + string? VerifierComment, + DateTime? DecidedAtUtc, + DateTime? ExecutedAtUtc, + string? ExecutionError); + +/// Result wrapper for . +public record SecuredWriteListResult(IReadOnlyList Items); diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index a1d6e950..8ef7e113 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -11,6 +11,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; @@ -216,6 +217,15 @@ public class ManagementActor : ReceiveActor or RetryParkedMessageCommand or DiscardParkedMessageCommand or DebugSnapshotCommand => Roles.Deployer, + // Two-person secured write (M7 / T14b). Submit is an Operator action; + // approve/reject are Verifier actions. Separation of duties (a write may + // not be verified by its submitter) is enforced inside the handler — the + // role gate only ensures the caller holds the right coarse role. + SubmitSecuredWriteCommand => Roles.Operator, + RejectSecuredWriteCommand or ApproveSecuredWriteCommand => Roles.Verifier, + // ListSecuredWritesCommand is read-only -> falls through to "any + // authenticated user" below, like the other list/query commands. + // Read-only queries -- any authenticated user _ => null }; @@ -368,6 +378,13 @@ public class ManagementActor : ReceiveActor DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user), DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user), + // Secured writes (M7 / T14b). Approve (execute) is intentionally NOT + // dispatched here — the approve->execute relay is Task C3; it would + // 'NotSupported' at runtime until then. + SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user), + RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user), + ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd), + // Transport (#24) bundle operations ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username), PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd), @@ -860,6 +877,91 @@ public class ManagementActor : ReceiveActor return await commService.DiscardParkedMessageAsync(cmd.SiteIdentifier, request); } + // ======================================================================== + // Secured-write handlers (M7 / T14b) + // + // Two-person workflow: an Operator submits a pending write against an + // MxGateway data connection; a distinct Verifier approves (Task C3) or + // rejects it. The role gate (GetRequiredRole) only verifies the coarse role; + // the separation-of-duties rule (a write may not be verified by its + // submitter) is enforced here. + // ======================================================================== + + private static SecuredWriteDto ToSecuredWriteDto(PendingSecuredWrite e) => new( + e.Id, e.SiteId, e.ConnectionName, e.TagPath, e.ValueJson, e.ValueType, + e.Status, e.OperatorUser, e.OperatorComment, e.SubmittedAtUtc, + e.VerifierUser, e.VerifierComment, e.DecidedAtUtc, e.ExecutedAtUtc, e.ExecutionError); + + private static async Task HandleSubmitSecuredWrite( + IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user) + { + var siteRepo = sp.GetRequiredService(); + var site = await siteRepo.GetSiteByIdentifierAsync(cmd.SiteId) + ?? throw new ManagementCommandException($"Site '{cmd.SiteId}' not found."); + + var connections = await siteRepo.GetDataConnectionsBySiteIdAsync(site.Id); + var conn = connections.FirstOrDefault(c => + string.Equals(c.Name, cmd.ConnectionName, StringComparison.Ordinal)) + ?? throw new ManagementCommandException( + $"Data connection '{cmd.ConnectionName}' not found on site '{cmd.SiteId}'."); + + // Secured writes only apply to MxGateway connections. + if (!string.Equals(conn.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase)) + throw new ManagementCommandException( + $"Secured writes require an MxGateway connection; '{cmd.ConnectionName}' uses protocol '{conn.Protocol}'."); + + var entity = new PendingSecuredWrite + { + SiteId = cmd.SiteId, + ConnectionName = cmd.ConnectionName, + TagPath = cmd.TagPath, + ValueJson = cmd.ValueJson, + ValueType = cmd.ValueType, + Status = "Pending", + OperatorUser = user.Username, + OperatorComment = cmd.Comment, + SubmittedAtUtc = DateTime.UtcNow + }; + + var repo = sp.GetRequiredService(); + entity.Id = await repo.AddAsync(entity); + return ToSecuredWriteDto(entity); + } + + private static async Task HandleRejectSecuredWrite( + IServiceProvider sp, RejectSecuredWriteCommand cmd, AuthenticatedUser user) + { + var repo = sp.GetRequiredService(); + var entity = await repo.GetAsync(cmd.Id) + ?? throw new ManagementCommandException($"Secured write {cmd.Id} not found."); + + if (!string.Equals(entity.Status, "Pending", StringComparison.Ordinal)) + throw new ManagementCommandException( + $"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected."); + + // Separation of duties: the verifier must differ from the submitter. + if (string.Equals(entity.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase)) + throw new ManagementCommandException( + "A secured write cannot be verified by its submitter."); + + entity.Status = "Rejected"; + entity.VerifierUser = user.Username; + entity.VerifierComment = cmd.Comment; + entity.DecidedAtUtc = DateTime.UtcNow; + + // UpdateAsync overwrites all columns -> pass the fully-populated entity. + await repo.UpdateAsync(entity); + return ToSecuredWriteDto(entity); + } + + private static async Task HandleListSecuredWrites( + IServiceProvider sp, ListSecuredWritesCommand cmd) + { + var repo = sp.GetRequiredService(); + var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip: 0, take: 200); + return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList()); + } + // ======================================================================== // Site handlers // ======================================================================== diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs new file mode 100644 index 00000000..aa30bda8 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/SecuredWriteHandlerTests.cs @@ -0,0 +1,273 @@ +using Akka.Actor; +using Akka.TestKit.Xunit2; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; +using ZB.MOM.WW.ScadaBridge.Commons.Types; + +namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests; + +/// +/// Handler tests for the two-person ("secured") write submit/reject/list flow +/// (M7 OPC UA / MxGateway UX, Task T14b). Submit is gated to Operator, reject to +/// Verifier; the no-self-approval rule and the MxGateway-protocol precondition +/// are enforced inside the handlers and surface as ManagementError. +/// +public class SecuredWriteHandlerTests : TestKit, IDisposable +{ + private readonly ISiteRepository _siteRepo; + private readonly ISecuredWriteRepository _securedWriteRepo; + private readonly ServiceCollection _services; + + public SecuredWriteHandlerTests() + { + _siteRepo = Substitute.For(); + _securedWriteRepo = Substitute.For(); + + _services = new ServiceCollection(); + _services.AddScoped(_ => _siteRepo); + _services.AddScoped(_ => _securedWriteRepo); + } + + private IActorRef CreateActor() + { + var sp = _services.BuildServiceProvider(); + return Sys.ActorOf(Props.Create(() => new ManagementActor( + sp, NullLogger.Instance))); + } + + private static ManagementEnvelope Envelope(object command, string username, params string[] roles) => + new(new AuthenticatedUser(username, username, roles, Array.Empty()), + command, Guid.NewGuid().ToString("N")); + + void IDisposable.Dispose() => Shutdown(); + + /// Wires a site whose single connection uses the given protocol. + private void SeedSiteWithConnection(int siteId, string identifier, string connectionName, string protocol) + { + _siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any()) + .Returns(new Site($"Site{siteId}", identifier) { Id = siteId }); + _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any()) + .Returns(new List + { + new(connectionName, protocol, siteId) { Id = 100 } + }); + } + + // ------------------------------------------------------------------------ + // Submit + // ------------------------------------------------------------------------ + + [Fact] + public void Submit_OnNonMxGatewayConnection_ReturnsError_NoRowInserted() + { + SeedSiteWithConnection(1, "SITE1", "Plc1", "OpcUa"); + + var actor = CreateActor(); + var envelope = Envelope( + new SubmitSecuredWriteCommand("SITE1", "Plc1", "Tag.A", "true", "Boolean", "go"), + "alice", "Operator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + Assert.Contains("MxGateway", response.Error); + _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + } + + [Fact] + public void Submit_OnUnknownConnection_ReturnsError_NoRowInserted() + { + SeedSiteWithConnection(1, "SITE1", "Plc1", "MxGateway"); + + var actor = CreateActor(); + var envelope = Envelope( + new SubmitSecuredWriteCommand("SITE1", "DoesNotExist", "Tag.A", "true", "Boolean", null), + "alice", "Operator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default); + } + + [Fact] + public void Submit_OnMxGatewayConnection_InsertsPendingRowWithOperatorUser() + { + SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway"); + + PendingSecuredWrite? inserted = null; + _securedWriteRepo + .When(r => r.AddAsync(Arg.Any(), Arg.Any())) + .Do(ci => inserted = ci.Arg()); + _securedWriteRepo.AddAsync(Arg.Any(), Arg.Any()) + .Returns(55L); + + var actor = CreateActor(); + var envelope = Envelope( + new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise setpoint"), + "alice", "Operator"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.NotNull(inserted); + Assert.Equal("Pending", inserted!.Status); + Assert.Equal("alice", inserted.OperatorUser); + Assert.Equal("Mx1", inserted.ConnectionName); + Assert.Equal("SITE1", inserted.SiteId); + Assert.Equal("Tag.Setpoint", inserted.TagPath); + Assert.Equal("42.5", inserted.ValueJson); + Assert.Equal("Double", inserted.ValueType); + Assert.Equal("raise setpoint", inserted.OperatorComment); + // The returned DTO carries the store-assigned id and operator. + Assert.Contains("\"id\":55", response.JsonData); + Assert.Contains("alice", response.JsonData); + } + + // ------------------------------------------------------------------------ + // Reject + // ------------------------------------------------------------------------ + + private PendingSecuredWrite SeedPendingWrite(long id, string operatorUser) + { + var row = new PendingSecuredWrite + { + Id = id, + SiteId = "SITE1", + ConnectionName = "Mx1", + TagPath = "Tag.A", + ValueJson = "true", + ValueType = "Boolean", + Status = "Pending", + OperatorUser = operatorUser, + SubmittedAtUtc = DateTime.UtcNow + }; + _securedWriteRepo.GetAsync(id, Arg.Any()).Returns(row); + return row; + } + + [Fact] + public void Reject_BySubmittingUser_ReturnsError_StatusUnchanged() + { + SeedPendingWrite(7, operatorUser: "alice"); + + var actor = CreateActor(); + // Same principal that submitted attempts to reject — separation of duties. + var envelope = Envelope(new RejectSecuredWriteCommand(7, "no"), "alice", "Verifier"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default); + } + + [Fact] + public void Reject_ByDifferentUser_FlipsStatusToRejected() + { + SeedPendingWrite(7, operatorUser: "alice"); + + PendingSecuredWrite? updated = null; + _securedWriteRepo + .When(r => r.UpdateAsync(Arg.Any(), Arg.Any())) + .Do(ci => updated = ci.Arg()); + + var actor = CreateActor(); + var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.NotNull(updated); + Assert.Equal("Rejected", updated!.Status); + Assert.Equal("bob", updated.VerifierUser); + Assert.Equal("not authorized", updated.VerifierComment); + Assert.NotNull(updated.DecidedAtUtc); + // Operator fields are preserved (fully-populated update). + Assert.Equal("alice", updated.OperatorUser); + } + + [Fact] + public void Reject_NonPendingRow_ReturnsError() + { + var row = SeedPendingWrite(7, operatorUser: "alice"); + row.Status = "Approved"; + + var actor = CreateActor(); + var envelope = Envelope(new RejectSecuredWriteCommand(7, null), "bob", "Verifier"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default); + } + + [Fact] + public void Reject_MissingRow_ReturnsError() + { + _securedWriteRepo.GetAsync(99, Arg.Any()).Returns((PendingSecuredWrite?)null); + + var actor = CreateActor(); + var envelope = Envelope(new RejectSecuredWriteCommand(99, null), "bob", "Verifier"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal("COMMAND_FAILED", response.ErrorCode); + } + + // ------------------------------------------------------------------------ + // List + // ------------------------------------------------------------------------ + + [Fact] + public void List_FiltersByStatusAndSite() + { + _securedWriteRepo.QueryAsync("Pending", "SITE1", 0, 200, Arg.Any()) + .Returns(new List + { + new() + { + Id = 3, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A", + ValueJson = "true", ValueType = "Boolean", Status = "Pending", + OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow + } + }); + + var actor = CreateActor(); + var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Viewer"); + + actor.Tell(envelope); + + var response = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(envelope.CorrelationId, response.CorrelationId); + Assert.Contains("\"id\":3", response.JsonData); + Assert.Contains("alice", response.JsonData); + _securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any()); + } + + [Fact] + public void List_NoFilters_PassesNullsToRepository() + { + _securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any()) + .Returns(new List()); + + var actor = CreateActor(); + var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol"); + + actor.Tell(envelope); + + ExpectMsg(TimeSpan.FromSeconds(5)); + _securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any()); + } +}