diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index 1f010103..92637b25 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -207,9 +207,9 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B - **QuerySiteEventLog**: Query site event log entries from a remote site (routed via communication layer). Supports date range, keyword search, and pagination. - **QueryParkedMessages**: Query parked (dead-letter) messages at a remote site (routed via communication layer). Supports pagination. - **DebugSnapshot**: Request a one-shot snapshot of attribute values and alarm states for a running instance. Resolves the instance's site from the config DB and routes via the communication layer. Uses 30s `QueryTimeout`. -- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. (`Design` role.) -- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. (`Design` role.) -- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. (`Design` role — runs inside the Admin-gated connection editor.) +- **BrowseNodeCommand**: Browse an OPC UA / MxGateway connection's address space one level at a time; supports a `BrowseNext` continuation token (paging) and returns per-node type-info for OPC UA Variables. Actor-dispatched — the handler requires the command's `SiteIdentifier`, enforces site-scope, then relays via `CommunicationService.BrowseNodeAsync` (arch-review C4, CLI/UI parity). (`Design` role.) +- **SearchAddressSpaceCommand**: Bounded recursive address-space search (depth + result caps, case-insensitive substring) against an OPC UA connection. Actor-dispatched, same `SiteIdentifier`-required + site-scope pattern, relayed via `CommunicationService.SearchAddressSpaceAsync` (arch-review C4). (`Design` role.) +- **VerifyEndpointCommand**: Ask a site to probe an OPC UA endpoint (temporary client, short timeout) and report success / typed failure / a captured-but-untrusted server cert. Read-only — never trusts. Actor-dispatched, `SiteIdentifier`-required + site-scope, relayed via `CommunicationService.VerifyEndpointAsync` (arch-review C4). (`Design` role — runs inside the Admin-gated connection editor.) - **TrustServerCertCommand** / **RemoveServerCertCommand** / **ListServerCertsCommand**: Manage the site's OPC UA trusted-peer PKI store; Trust/Remove broadcast to **both** site nodes (see Component-SiteRuntime.md). (`Admin` role.) ## Authorization diff --git a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs index abf693c3..115754f3 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Communication/CommunicationService.cs @@ -363,7 +363,7 @@ public class CommunicationService /// The OPC UA browse command. /// Cancellation token. /// The browse result (children + truncation flag + structured failure). - public Task BrowseNodeAsync( + public virtual Task BrowseNodeAsync( string siteId, BrowseNodeCommand command, CancellationToken cancellationToken = default) @@ -385,7 +385,7 @@ public class CommunicationService /// The verify-endpoint command (connection name + protocol + serialized config). /// Cancellation token. /// The verification result (success or classified failure, with a captured certificate when the failure is an untrusted certificate). - public Task VerifyEndpointAsync( + public virtual Task VerifyEndpointAsync( string siteId, VerifyEndpointCommand command, CancellationToken cancellationToken = default) @@ -407,7 +407,7 @@ public class CommunicationService /// The address-space search command (connection name + query + depth/result caps). /// Cancellation token. /// The search result (matches + cap-reached flag + structured failure). - public Task SearchAddressSpaceAsync( + public virtual Task SearchAddressSpaceAsync( string siteId, SearchAddressSpaceCommand command, CancellationToken cancellationToken = default) diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index 732598c4..79556338 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -243,6 +243,11 @@ public class ManagementActor : ReceiveActor or CreateTemplateFolderCommand or RenameTemplateFolderCommand or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand or MoveTemplateToFolderCommand + // OPC UA / MxGateway design-time remote queries (arch-review C4): + // browse the address space, run a bounded search, and probe/verify an + // endpoint. Read-only against the live site server; gated Design to + // match the connection editor these back. + or BrowseNodeCommand or SearchAddressSpaceCommand or VerifyEndpointCommand or ExportBundleCommand => DesignerOnly, // Transport import operations (mirror the Central UI gating: @@ -435,6 +440,9 @@ public class ManagementActor : ReceiveActor // Remote Queries QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user), + BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user), + SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user), + VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user), QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user), RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user), DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user), @@ -2914,6 +2922,39 @@ public class ManagementActor : ReceiveActor return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request); } + // OPC UA / MxGateway design-time remote queries (arch-review C4). These + // handlers give the management API/CLI parity with the Central UI, which + // already reaches these site actors directly. SiteIdentifier is REQUIRED — + // the actor must know which site to route to — and site-scope is enforced + // before relaying, exactly like HandleQueryEventLogs. + + private static async Task HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user) + { + var site = cmd.SiteIdentifier + ?? throw new ManagementCommandException("SiteIdentifier is required when browsing an address space via the management API."); + await EnforceSiteScopeForIdentifier(sp, user, site); + var commService = sp.GetRequiredService(); + return await commService.BrowseNodeAsync(site, cmd); + } + + private static async Task HandleSearchAddressSpace(IServiceProvider sp, SearchAddressSpaceCommand cmd, AuthenticatedUser user) + { + var site = cmd.SiteIdentifier + ?? throw new ManagementCommandException("SiteIdentifier is required when searching an address space via the management API."); + await EnforceSiteScopeForIdentifier(sp, user, site); + var commService = sp.GetRequiredService(); + return await commService.SearchAddressSpaceAsync(site, cmd); + } + + private static async Task HandleVerifyEndpoint(IServiceProvider sp, VerifyEndpointCommand cmd, AuthenticatedUser user) + { + var site = cmd.SiteIdentifier + ?? throw new ManagementCommandException("SiteIdentifier is required when verifying an endpoint via the management API."); + await EnforceSiteScopeForIdentifier(sp, user, site); + var commService = sp.GetRequiredService(); + return await commService.VerifyEndpointAsync(site, cmd); + } + private static async Task HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user) { await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier); diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 46be4b0a..888c80bc 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -2,13 +2,16 @@ using Akka.Actor; using Akka.TestKit.Xunit2; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; using NSubstitute; +using ZB.MOM.WW.ScadaBridge.Communication; 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.Protocol; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services; using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; @@ -3152,4 +3155,142 @@ public class ManagementActorTests : TestKit, IDisposable actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer")); ExpectMsg(TimeSpan.FromSeconds(5)); } + + // ======================================================================== + // Remote-query actor dispatch for the OPC UA design-time commands + // (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain + // actor handlers so the management API/CLI reaches parity with the UI. + // SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated. + // ======================================================================== + + [Fact] + public void BrowseNode_WithoutSiteIdentifier_ReturnsError() + { + var actor = CreateActor(); + actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer")); + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("SiteIdentifier", resp.Error); + } + + [Fact] + public void BrowseNode_Designer_RelaysToSite() + { + var stub = new StubCommunicationService(); + _services.AddSingleton(stub); + var actor = CreateActor(); + actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(1, stub.BrowseCount); + Assert.Equal("SITE1", stub.LastBrowseSite); + } + + [Fact] + public void BrowseNode_ViewerOnly_Unauthorized() + { + var actor = CreateActor(); + actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError() + { + var actor = CreateActor(); + actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50), "Designer")); + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("SiteIdentifier", resp.Error); + } + + [Fact] + public void SearchAddressSpace_Designer_RelaysToSite() + { + var stub = new StubCommunicationService(); + _services.AddSingleton(stub); + var actor = CreateActor(); + actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(1, stub.SearchCount); + Assert.Equal("SITE1", stub.LastSearchSite); + } + + [Fact] + public void SearchAddressSpace_ViewerOnly_Unauthorized() + { + var actor = CreateActor(); + actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Viewer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError() + { + var actor = CreateActor(); + actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}"), "Designer")); + var resp = ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Contains("SiteIdentifier", resp.Error); + } + + [Fact] + public void VerifyEndpoint_Designer_RelaysToSite() + { + var stub = new StubCommunicationService(); + _services.AddSingleton(stub); + var actor = CreateActor(); + actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Designer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + Assert.Equal(1, stub.VerifyCount); + Assert.Equal("SITE1", stub.LastVerifySite); + } + + [Fact] + public void VerifyEndpoint_ViewerOnly_Unauthorized() + { + var actor = CreateActor(); + actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Viewer")); + ExpectMsg(TimeSpan.FromSeconds(5)); + } + + /// + /// Records remote-query relays for the actor-dispatch tests. The browse / + /// search / verify / cert-trust methods on + /// are virtual so the relay can be exercised without a live actor system. + /// + private sealed class StubCommunicationService : CommunicationService + { + public StubCommunicationService() + : base(Options.Create(new CommunicationOptions()), NullLogger.Instance) + { + } + + public int BrowseCount { get; private set; } + public string? LastBrowseSite { get; private set; } + public int SearchCount { get; private set; } + public string? LastSearchSite { get; private set; } + public int VerifyCount { get; private set; } + public string? LastVerifySite { get; private set; } + + public override Task BrowseNodeAsync( + string siteId, BrowseNodeCommand command, CancellationToken ct = default) + { + BrowseCount++; + LastBrowseSite = siteId; + return Task.FromResult(new BrowseNodeResult(Array.Empty(), false, null)); + } + + public override Task SearchAddressSpaceAsync( + string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default) + { + SearchCount++; + LastSearchSite = siteId; + return Task.FromResult(new SearchAddressSpaceResult(Array.Empty(), false, null)); + } + + public override Task VerifyEndpointAsync( + string siteId, VerifyEndpointCommand command, CancellationToken ct = default) + { + VerifyCount++; + LastVerifySite = siteId; + return Task.FromResult(new VerifyEndpointResult(true, null, null, null)); + } + } }