fix(central-ui): Flow Debugger Replay opens to OperationalAudit roles; Live run and Retry/Discard stay Deployer-only (arch-review F3)

Design D6 / §9 says a Live run is a Deployer act while Replay needs only
OperationalAudit, but the page and all three FlowDebug* commands were gated on
Deployer. The Audit Log and Execution Chain pages -- themselves OperationalAudit
-- show an "Open in Flow Debugger" drill-in that an auditor could not follow.

Implements the split:

- New `RequireFlowDebugger` policy over `FlowDebuggerAccessRoles`
  {Deployer, Administrator, Viewer}; the page now carries it instead of
  `RequireDeployment`.
- `FlowDebugReplayCommand` takes the same role set in the ManagementActor
  matrix; `FlowDebugRunMethodCommand` / `FlowDebugRunValueChangeCommand` stay
  DeployerOnly.
- The page resolves `_canLive` (Deployer) in OnInitializedAsync and, without it,
  pins the mode to Replay, hides the entry-point and mode toggles, the Run
  button and the Live pickers, honours only the `?executionId=` drill-in, and
  leaves the parked-node Retry/Discard callbacks unset so those buttons never
  render. `CanRun`, `SetMode(Live)` and `ActOnStepAsync` repeat the check so the
  split does not rest on markup alone.

The NavMenu entry stays inside the Deployer-gated Deployment section -- auditors
arrive through the audit drill-ins, which is the point of the finding.

Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
This commit is contained in:
Joseph Doherty
2026-09-06 14:13:32 -04:00
parent cccef7b2f4
commit f86eb61d1a
8 changed files with 266 additions and 27 deletions
@@ -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<FlowDebuggerPage>();
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<CancellationToken>()).Returns(parked);
var nav = Services.GetRequiredService<NavigationManager>();
nav.NavigateTo($"/deployment/flow-debugger?executionId={root}");
var cut = Render<FlowDebuggerPage>();
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<CancellationToken>());
}
[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<NavigationManager>();
nav.NavigateTo("/deployment/flow-debugger?methodId=4");
var cut = Render<FlowDebuggerPage>();
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]
@@ -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));
}
}
@@ -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<string, string[]?> 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],
@@ -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<Claim>();