fix(flow-debugger): refuse method-entry Live runs for site-scoped users (#55)

Owner decision (b), 2026-11-09. An inbound API method has no site
affinity, so the F2 route-time scope only bounded routed SITE traffic;
the method's central-side effects (Database.*, ExternalSystem,
notifications) still ran for a scoped Deployer. FlowDebugRunner now
refuses FlowDebugRunMethodCommand for a site-scoped caller (site ids,
not Administrator) before the script executes, audits the refusal as a
failed InboundRequest under the operator's name, and raises
SiteScopeViolationException -> ManagementUnauthorized, so the Central
UI and the CLI (same /management command) share the gate.

The page disables the "Inbound API method" entry for scoped Deployers
with the reason (tooltip + inline note), starts them on script entry,
and ignores ?methodId= / a stored method choice. Replay and the
instance-scoped script / value-change Live runs are unchanged. The
route-time scope is kept as defence in depth.

Tests: scoped refused (in- and out-of-scope routing; script never runs,
refusal audited), unscoped/Administrator allowed, scoped Replay allowed,
scoped in-scope value-change Live still runs; bUnit for the disabled
entry, the drill-in, and the unscoped control. Mutation-proven (gate
and UI flag reverted -> 4 red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:22:44 -05:00
parent 4624c8db28
commit 63ec45e2e3
11 changed files with 198 additions and 25 deletions
@@ -167,6 +167,55 @@ public class FlowDebuggerPageTests : BunitContext
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
}
// ---- Gitea #55: a site-scoped Deployer gets no method-entry Live run -------------------------
/// <summary>Verifies that a site-scoped deployer sees the method entry disabled with an explanation, and starts on script entry.</summary>
[Fact]
public void ScopedDeployer_MethodEntryIsDisabledAndExplained_ScriptEntryStaysAvailable()
{
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "1")]);
var cut = Render<FlowDebuggerPage>();
var method = cut.Find("button[data-test='flow-entry-method']");
Assert.True(method.HasAttribute("disabled"));
Assert.Contains("site-scoped", method.GetAttribute("title"));
Assert.Contains("site-scoped", cut.Find("[data-test='flow-method-live-refused']").TextContent);
// Script entry is the starting entry, so its site picker — not the method select — is shown.
Assert.False(cut.Find("button[data-test='flow-entry-script']").HasAttribute("disabled"));
Assert.NotNull(cut.Find("select[data-test='flow-site-select']"));
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
// Replay stays one click away.
Assert.NotNull(cut.Find("button[data-test='flow-mode-replay']"));
}
/// <summary>Verifies that a site-scoped deployer drilling in with a method id never reaches a runnable method pane.</summary>
[Fact]
public void ScopedDeployer_DrillInWithAMethodId_DoesNotSelectTheMethod()
{
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "1")]);
Services.GetRequiredService<NavigationManager>().NavigateTo("/deployment/flow-debugger?methodId=4");
var cut = Render<FlowDebuggerPage>();
Assert.Empty(cut.FindAll("select[data-test='flow-method-select']"));
Assert.True(cut.Find("button[data-test='flow-run']").HasAttribute("disabled"));
_flow.DidNotReceive().PredictMethodAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>Verifies that an unscoped deployer keeps the method entry enabled with no refusal note.</summary>
[Fact]
public void UnscopedDeployer_MethodEntryStaysEnabled()
{
RegisterServices();
var cut = Render<FlowDebuggerPage>();
Assert.False(cut.Find("button[data-test='flow-entry-method']").HasAttribute("disabled"));
Assert.Empty(cut.FindAll("[data-test='flow-method-live-refused']"));
Assert.NotNull(cut.Find("select[data-test='flow-method-select']"));
}
// ---- F3: Replay opens to the OperationalAudit roles; Live stays Deployer-only ----------------
/// <summary>Verifies that the page renders replay-only for a viewer, with no live run controls.</summary>
@@ -457,10 +506,17 @@ public class FlowDebuggerPageTests : BunitContext
public void ParkedNode_OutOfScopeSite_RefusedBeforeConfirm()
{
// Deployer scoped to site 2 only; the parked call belongs to site-a (id 1).
// A scoped Deployer cannot Live-run a method (Gitea #55), so the parked node is reached through Replay.
RegisterServices(extraClaims: [new Claim(JwtTokenService.SiteIdClaimType, "2")]);
var root = Guid.NewGuid();
var (parked, _, _) = ParkedThenResolved(root);
var cut = RunToParkedNode(root, parked);
_flow.ReplayAsync(root, Arg.Any<CancellationToken>()).Returns(parked);
Services.GetRequiredService<NavigationManager>().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();
cut.WaitForElement("button[data-test='flow-step-retry']");
cut.Find("button[data-test='flow-step-retry']").Click();