diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor index 815c7d6e..91b7cac6 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor @@ -3,7 +3,10 @@ @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Deployment @using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared @using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging -@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)] +@* arch-review F3: opening the page is a Replay-capable read (Deployer + the OperationalAudit + roles) so the Audit Log / Execution Chain drill-ins work for an auditor. Every Live-run + affordance below is additionally gated on _canLive (the Deployer role). *@ +@attribute [Authorize(Policy = AuthorizationPolicies.RequireFlowDebugger)] Flow Debugger @@ -13,15 +16,19 @@ @* ── Toolbar ─────────────────────────────────────────────────────────── *@
-
- -
- - + @* The entry point only selects what a Live run starts from, so it is Deployer-only. *@ + @if (_canLive) + { +
+ +
+ + +
-
+ } @if (_mode == FlowMode.Live) { @@ -85,15 +92,19 @@
} -
- -
- - + @* Without Deployer there is only Replay, so the toggle would be a single dead button. *@ + @if (_canLive) + { +
+ +
+ + +
-
+ }
@if (_mode == FlowMode.Live) @@ -115,6 +126,14 @@
+ @if (!_canLive) + { +
+ Replay only — rebuilding a past execution from the audit log. Starting a live run + requires the Deployer role. +
+ } + @* ── Inline alerts ───────────────────────────────────────────────────── *@ @if (_error != null) { @@ -232,10 +251,13 @@ @* ── Detail panel ──────────────────────────────────────────────────── *@
+ @* Retry / Discard relay a command to the site — only wired for a Deployer, so the + buttons never render for an auditor (arch-review F3). *@ + OnRetry="RetryCallback" OnDiscard="DiscardCallback" + ActionInProgress="_actionInProgress" />
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs index 7e5df08e..7fa894fa 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/FlowDebugger.razor.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Authorization; using Microsoft.JSInterop; using ZB.MOM.WW.Audit; using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; @@ -15,6 +16,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging; using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi; using ZB.MOM.WW.ScadaBridge.ScriptAnalysis; +using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment; @@ -76,6 +78,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable [Inject] private SiteScopeService SiteScope { get; set; } = default!; [Inject] private IJSRuntime JS { get; set; } = default!; [Inject] private IDialogService Dialog { get; set; } = default!; + [Inject] private AuthenticationStateProvider Auth { get; set; } = default!; /// Drill-in: pre-select an inbound method. [SupplyParameterFromQuery] public int? MethodId { get; set; } @@ -93,6 +96,16 @@ public partial class FlowDebugger : ComponentBase, IDisposable private FlowMode _mode = FlowMode.Live; private FlowState _state = FlowState.Idle; + /// + /// True when the principal holds and may therefore start a + /// Live run (and relay Retry/Discard on a parked node). The page itself opens to the + /// wider set so an auditor can + /// follow the Audit Log / Execution Chain drill-in into Replay (arch-review F3); every + /// Live affordance is hidden when this is false, and the server enforces the same split + /// (the two run commands stay Deployer-only in the ManagementActor role matrix). + /// + private bool _canLive; + private List _methods = []; private List _sites = []; private List _instances = []; @@ -146,7 +159,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable private bool CanRefresh => !_running && _raw is not null; - private bool CanRun => !_running && _mode == FlowMode.Live && ( + private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && ( _entry == EntryKind.Method ? _selectedMethodId > 0 : _selectedInstanceId > 0 && !string.IsNullOrWhiteSpace(_selectedAttribute)); @@ -184,9 +197,29 @@ public partial class FlowDebugger : ComponentBase, IDisposable /// protected override async Task OnInitializedAsync() { + // arch-review F3: page access is the wider RequireFlowDebugger set; only a Deployer + // gets the Live half. Resolved before anything else so the Live-only drill-ins below + // are skipped and the mode starts on Replay for an auditor. + _canLive = (await Auth.GetAuthenticationStateAsync()).User + .HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer); + if (!_canLive) + _mode = FlowMode.Replay; + _methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList(); _sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync()); + if (!_canLive) + { + // ?methodId= / ?instanceId= are Live-run drill-ins; ?executionId= is the audit + // drill-in and is the only one that means anything without Deployer. + if (ExecutionId is Guid auditExec && auditExec != Guid.Empty) + { + _replayExecutionIdText = auditExec.ToString(); + await LoadReplayAsync(); + } + return; + } + if (InstanceId is int iid && iid > 0) { _entry = EntryKind.ValueChange; @@ -383,6 +416,8 @@ public partial class FlowDebugger : ComponentBase, IDisposable private void SetMode(FlowMode mode) { if (_mode == mode) return; + // Without Deployer there is no Live half to switch to (arch-review F3). + if (mode == FlowMode.Live && !_canLive) return; CancelPendingRefresh(); _mode = mode; } @@ -558,6 +593,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable private Task OnDiscardStepAsync(FlowStep step) => ActOnStepAsync(step, retry: false); + /// + /// The parked-node Retry callback, left unset (default) without the Deployer role so + /// FlowStepDetail's OnRetry.HasDelegate check hides the button (arch-review F3). + /// + private EventCallback RetryCallback + => _canLive ? EventCallback.Factory.Create(this, OnRetryStepAsync) : default; + + /// The parked-node Discard callback; unset without Deployer, as . + private EventCallback DiscardCallback + => _canLive ? EventCallback.Factory.Create(this, OnDiscardStepAsync) : default; + /// /// Retry / Discard a Parked outbound node: site-scope re-check → confirm → relay through the /// service → toast → re-poll Replay until the node leaves Parked. Mirrors the Site Calls / @@ -565,7 +611,10 @@ public partial class FlowDebugger : ComponentBase, IDisposable /// private async Task ActOnStepAsync(FlowStep step, bool retry) { - if (_actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr) + // _canLive: relaying Retry/Discard to the site is a Deployer act. The buttons are not + // rendered without it (the callbacks are left unset), but the gate is repeated here so + // the split does not rest on markup alone (arch-review F3). + if (!_canLive || _actionInProgress || step.Outcome != FlowStepOutcome.Parked || step.CorrelationId is not { } corr) return; if (!await IsStepSiteAllowedAsync(step)) diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index b5b9772e..fe8dc090 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -195,6 +195,11 @@ public class ManagementActor : ReceiveActor private static readonly string[] OperatorOnly = [Roles.Operator]; private static readonly string[] VerifierOnly = [Roles.Verifier]; + // arch-review F3: Flow Debugger Replay is an audit read, so it carries the same role + // set as the RequireFlowDebugger page policy ({Deployer, Administrator, Viewer}). The + // two Live run commands stay DeployerOnly. + private static readonly string[] FlowDebuggerReplayRoles = AuthorizationPolicies.FlowDebuggerAccessRoles; + // arch-review C6: area management is reconciled to any-of [Designer, Deployer]. // Areas are authored on both real surfaces — the Designer template/topology // tooling AND the Central UI Deployment Topology workflow (Topology.razor is @@ -305,9 +310,13 @@ public class ManagementActor : ReceiveActor or RetryParkedMessageCommand or DiscardParkedMessageCommand or DebugSnapshotCommand => DeployerOnly, - // Flow Debugger (design 2026-09-05 §9): Live runs execute scripts and write attributes - // on operator input, Replay reads the audit chain — Deployer, like the Debug View. - FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand or FlowDebugReplayCommand => DeployerOnly, + // Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3). A Live run executes + // scripts and writes attributes on operator input, so it stays Deployer-only, like + // the Debug View. Replay only reads the audit chain, so it additionally accepts the + // OperationalAudit roles — the Audit Log / Execution Chain drill-ins are the + // feature's main entry for a past incident and an auditor must be able to use them. + FlowDebugRunMethodCommand or FlowDebugRunValueChangeCommand => DeployerOnly, + FlowDebugReplayCommand => FlowDebuggerReplayRoles, // Two-person secured write. Submit is an Operator action; // approve/reject are Verifier actions. Separation of duties (a write may diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs index 07dad10d..c691fd24 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/AuthorizationPolicies.cs @@ -149,6 +149,33 @@ public static class AuthorizationPolicies /// public static readonly string[] AuditExportRoles = { Roles.Administrator }; + /// + /// Permission to OPEN the Flow Debugger page (arch-review F3 / design D6). + /// Replay only reads the audit chain, and the Audit Log / Execution Chain + /// pages — themselves gated on — carry an + /// "Open in Flow Debugger" drill-in that is the feature's main entry for a + /// past incident, so an auditor must be able to follow it. + /// + /// + /// This is page ACCESS only, and is deliberately NOT sufficient for a Live + /// run: the Run controls, the Live entry-point pickers and the parked-node + /// Retry/Discard actions are gated on in the + /// page, and FlowDebugRunMethodCommand / + /// FlowDebugRunValueChangeCommand stay Deployer-only in the + /// ManagementActor role matrix. Only FlowDebugReplayCommand accepts + /// . + /// + public const string RequireFlowDebugger = "RequireFlowDebugger"; + + /// + /// Roles that satisfy — the + /// plus Deployer. Held in one + /// place so the policy, the page and the ManagementActor's + /// FlowDebugReplayCommand gate stay in lockstep. + /// + public static readonly string[] FlowDebuggerAccessRoles = + { Roles.Deployer, Roles.Administrator, Roles.Viewer }; + /// /// Registers the ScadaBridge authorization policies (Admin, Design, Deployment, OperationalAudit, AuditExport). /// @@ -191,6 +218,12 @@ public static class AuthorizationPolicies options.AddPolicy(AuditExport, policy => policy.RequireClaim(JwtTokenService.RoleClaimType, AuditExportRoles)); + + // Flow Debugger page access (arch-review F3): opening the page is a + // Replay-capable read; the Live run controls are separately gated on + // RequireDeployment inside the page. + options.AddPolicy(RequireFlowDebugger, policy => + policy.RequireClaim(JwtTokenService.RoleClaimType, FlowDebuggerAccessRoles)); }); return services; diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs index 841bd228..55c974b7 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/FlowDebuggerPageTests.cs @@ -119,6 +119,73 @@ public class FlowDebuggerPageTests : BunitContext Assert.NotNull(cut.Find("[data-test='flow-graph-empty']")); Assert.Equal("Not run", cut.Find("[data-test='flow-state']").TextContent.Trim()); Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled")); + // arch-review F3: a Deployer gets the whole surface — entry point, both modes, Run. + Assert.NotNull(cut.Find("button[data-test='flow-entry-method']")); + Assert.NotNull(cut.Find("button[data-test='flow-mode-live']")); + Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']")); + } + + // ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ---------------- + + [Fact] + public void Renders_ForViewer_ReplayOnly_WithNoLiveControls() + { + // A Viewer holds OperationalAudit but not Deployer: the page opens (it is the + // Audit Log / Execution Chain drill-in target) pinned to Replay, with no way to + // start a Live run. + RegisterServices(role: "Viewer"); + + var cut = Render(); + + Assert.NotNull(cut.Find("[data-test='flow-toolbar']")); + Assert.NotNull(cut.Find("input[data-test='flow-execution-id']")); + Assert.NotNull(cut.Find("button[data-test='flow-load']")); + Assert.NotNull(cut.Find("[data-test='flow-replay-only']")); + + Assert.Empty(cut.FindAll("button[data-test='flow-run']")); + Assert.Empty(cut.FindAll("button[data-test='flow-mode-live']")); + Assert.Empty(cut.FindAll("button[data-test='flow-entry-method']")); + Assert.Empty(cut.FindAll("button[data-test='flow-entry-value-change']")); + Assert.Empty(cut.FindAll("select[data-test='flow-method-select']")); + } + + [Fact] + public void Viewer_LoadsAReplay_ButGetsNoRetryOrDiscardOnAParkedNode() + { + // Retry / Discard relay a command to the site — a Deployer act. The auditor sees + // the Parked node and its detail, but not the two action buttons. + RegisterServices(role: "Viewer"); + var root = Guid.NewGuid(); + var (parked, _, _) = ParkedThenResolved(root); + _flow.ReplayAsync(root, Arg.Any()).Returns(parked); + var nav = Services.GetRequiredService(); + nav.NavigateTo($"/deployment/flow-debugger?executionId={root}"); + + var cut = Render(); + cut.Instance.Delay = (_, _) => Task.CompletedTask; + + cut.WaitForAssertion(() => Assert.Equal("Completed", cut.Find("[data-test='flow-state']").TextContent.Trim())); + cut.Find("g[data-test='flow-node'][data-kind='ApiCall']").Click(); + + Assert.Empty(cut.FindAll("button[data-test='flow-step-retry']")); + Assert.Empty(cut.FindAll("button[data-test='flow-step-discard']")); + _flow.Received(1).ReplayAsync(root, Arg.Any()); + } + + [Fact] + public void Viewer_DrillInWithAMethodId_DoesNotOpenTheLiveRunSurface() + { + // ?methodId= is a Live-run drill-in. For a non-Deployer it must not put the page + // into Live mode or surface the Run button. + RegisterServices(role: "Viewer"); + var nav = Services.GetRequiredService(); + nav.NavigateTo("/deployment/flow-debugger?methodId=4"); + + var cut = Render(); + + Assert.Empty(cut.FindAll("button[data-test='flow-run']")); + Assert.Empty(cut.FindAll("select[data-test='flow-method-select']")); + Assert.NotNull(cut.Find("input[data-test='flow-execution-id']")); } [Fact] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs index 8ca0642b..c456bf81 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/FlowDebugRunnerTests.cs @@ -234,9 +234,20 @@ public class FlowDebugRunnerTests } [Fact] - public void RoleGate_AllThreeCommands_AreDeployerOnly() + public void RoleGate_TheTwoRunCommands_AreDeployerOnly() { - foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b"), new FlowDebugReplayCommand(Guid.NewGuid()) }) + // arch-review F3: a Live run executes scripts and writes attributes, so it stays Deployer-only. + foreach (var cmd in new object[] { new FlowDebugRunMethodCommand(1, null), new FlowDebugRunValueChangeCommand(1, "a", "b") }) Assert.Equal(new[] { "Deployer" }, ManagementActor.GetRequiredRoles(cmd)); } + + [Fact] + public void RoleGate_Replay_AlsoAcceptsTheOperationalAuditRoles() + { + // arch-review F3: Replay only reads the audit chain, and the Audit Log / Execution + // Chain drill-ins must work for an auditor who holds no Deployer role. + var actual = ManagementActor.GetRequiredRoles(new FlowDebugReplayCommand(Guid.NewGuid())); + Assert.NotNull(actual); + Assert.Equal(new[] { "Administrator", "Deployer", "Viewer" }, actual!.OrderBy(r => r, StringComparer.Ordinal)); + } } diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index a289ab04..b060ad34 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -23,6 +23,12 @@ public class RequiredRoleMatrixTests private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer]; private static readonly string[] SecuredWriteReaders = [Roles.Operator, Roles.Verifier, Roles.Administrator]; + // arch-review F3: Flow Debugger Replay is an audit read, so it is open to the + // OperationalAudit roles {Administrator, Viewer} in addition to Deployer. Written out + // by hand (not read from AuthorizationPolicies.FlowDebuggerAccessRoles) to keep the + // freeze meaningful — a change to the policy's role set must fail this table first. + private static readonly string[] FlowDebuggerReplayers = [Roles.Deployer, Roles.Administrator, Roles.Viewer]; + private static readonly Dictionary Expected = new(StringComparer.OrdinalIgnoreCase) { // ---- Administrator-only ----------------------------------------------------- @@ -138,10 +144,14 @@ public class RequiredRoleMatrixTests ["RetryParkedMessage"] = [Roles.Deployer], ["DiscardParkedMessage"] = [Roles.Deployer], ["DebugSnapshot"] = [Roles.Deployer], - // Flow Debugger (design 2026-09-05 §9): Live runs + Replay are Deployer, like Debug View. + // Flow Debugger (design 2026-09-05 §9 / D6, arch-review F3): a Live run executes + // scripts and writes attributes on operator input, so it stays Deployer-only. + // Replay only reads the audit chain, so it carries the OperationalAudit roles as + // well — the Audit Log / Execution Chain drill-ins are the feature's main entry + // for a past incident and an auditor must be able to follow them. ["FlowDebugRunMethod"] = [Roles.Deployer], ["FlowDebugRunValueChange"] = [Roles.Deployer], - ["FlowDebugReplay"] = [Roles.Deployer], + ["FlowDebugReplay"] = FlowDebuggerReplayers, // ---- Two-person secured write ---------------------------------------------- ["SubmitSecuredWrite"] = [Roles.Operator], diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs index 9bc9498f..c700baf1 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/SecurityTests.cs @@ -1264,6 +1264,44 @@ public class AuthorizationPolicyTests Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireSecuredWriteAccess, principal)); } + // ───────────────────────────────────────────────────────────────────── + // Flow Debugger page access (arch-review F3 / design D6). Replay is an + // audit read, so opening the page is any-of {Deployer, Administrator, + // Viewer} — the OperationalAudit roles plus Deployer. The Live run + // controls are separately gated on Deployer by the page and, server-side, + // by the ManagementActor role matrix; Designer gets neither. + // ───────────────────────────────────────────────────────────────────── + + [Theory] + [InlineData("Deployer")] + [InlineData("Administrator")] + [InlineData("Viewer")] + public async Task RequireFlowDebugger_ReplayCapableRoles_Satisfy(string role) + { + var principal = CreatePrincipal(new[] { role }); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal)); + } + + [Theory] + [InlineData("Designer")] + [InlineData("Operator")] + [InlineData("Verifier")] + public async Task RequireFlowDebugger_OtherRoles_Denied(string role) + { + var principal = CreatePrincipal(new[] { role }); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, principal)); + } + + [Fact] + public async Task RequireFlowDebugger_DoesNotImplyDeployment() + { + // A Viewer may open the page (Replay) but must not satisfy the policy the + // Live run controls are gated on. + var viewer = CreatePrincipal(new[] { Roles.Viewer }); + Assert.True(await EvaluatePolicy(AuthorizationPolicies.RequireFlowDebugger, viewer)); + Assert.False(await EvaluatePolicy(AuthorizationPolicies.RequireDeployment, viewer)); + } + private static ClaimsPrincipal CreatePrincipal(string[] roles, string[]? siteIds = null) { var claims = new List();