+ @* 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();