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:
@@ -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();
|
||||
|
||||
|
||||
@@ -575,37 +575,74 @@ public class FlowDebugRunnerTests
|
||||
return sites;
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer routing to another site is unauthorized and the inbound row is still written.</summary>
|
||||
// --- Gitea #55 (owner decision (b)): a site-scoped operator may not Live-run a method at all ---
|
||||
//
|
||||
// F2's route-time scope only bounds routed SITE traffic; a method's central-side effects
|
||||
// (Database.*, ExternalSystem, notifications) have no site to scope against. So a scoped
|
||||
// operator is refused before the script executes — whether or not it would route in scope.
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer is refused a method Live run, nothing executes, and the refusal is audited.</summary>
|
||||
/// <param name="routesTo">The site the method would route to — in or out of the operator's scope.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten()
|
||||
[Theory]
|
||||
[InlineData("site-a")] // within scope (id 1 → site-a): still refused, the central effects are unscoped
|
||||
[InlineData("site-b")] // outside scope
|
||||
public async Task RunMethod_ScopedDeployer_IsRefused_BeforeTheScriptRuns_AndTheRefusalIsAudited(string routesTo)
|
||||
{
|
||||
WireRoutedMethod(11, "inst", "site-b");
|
||||
WireRoutedMethod(11, "inst", routesTo);
|
||||
var ran = false;
|
||||
_executor.RegisterHandler("M", _ => { ran = true; return Task.FromResult<object?>(1); });
|
||||
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
|
||||
|
||||
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
|
||||
var ex = await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(11, null), scoped, CancellationToken.None));
|
||||
|
||||
// The site was never contacted...
|
||||
Assert.Equal(FlowDebugRunner.MethodLiveRefusedMessage(scoped), ex.Message);
|
||||
Assert.Contains("Replay", ex.Message);
|
||||
Assert.False(ran, "the method script must not execute for a site-scoped operator");
|
||||
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
|
||||
// ...but the denied attempt IS recorded against the operator's name.
|
||||
Assert.Contains(_central, e => e.AsRow().Kind == AuditKind.InboundRequest && e.Actor == "ops");
|
||||
// The refused attempt IS recorded against the operator's name.
|
||||
var row = Assert.Single(_central, e => e.AsRow().Kind == AuditKind.InboundRequest);
|
||||
Assert.Equal("ops", row.Actor);
|
||||
Assert.Equal(AuditStatus.Failed, row.AsRow().Status);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer routing within its scope runs the method.</summary>
|
||||
/// <summary>Verifies that a site-scoped deployer can still Replay a method execution.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs()
|
||||
public async Task Replay_ScopedDeployer_IsAllowed()
|
||||
{
|
||||
// The same scoped user, routing to a site their numeric id DOES resolve to.
|
||||
WireRoutedMethod(12, "inst", "site-a");
|
||||
var repo = Substitute.For<IAuditLogRepository>();
|
||||
var root = Guid.NewGuid();
|
||||
var rows = new[]
|
||||
{
|
||||
ScadaBridgeAuditEventFactory.Create(AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Delivered, executionId: root),
|
||||
};
|
||||
repo.GetTraceEventsAsync(root, Arg.Any<CancellationToken>(), Arg.Is<IReadOnlyList<string>>(s => s.SequenceEqual(new[] { "1" })))
|
||||
.Returns(new ZB.MOM.WW.ScadaBridge.Commons.Types.Audit.TraceEventBatch(root, rows, false));
|
||||
_services.AddSingleton(repo);
|
||||
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
|
||||
|
||||
var result = await Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(12, null), scoped, CancellationToken.None);
|
||||
var result = await Runner().ReplayAsync(_services.BuildServiceProvider(), new FlowDebugReplayCommand(root), CancellationToken.None, scoped);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
Assert.Equal(rows, result.Events);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that a site-scoped deployer's value-change Live run on a permitted instance still runs (instance-scoped entry is unchanged by #55).</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task RunValueChange_ScopedDeployer_InScopeSite_StillRuns()
|
||||
{
|
||||
var (_, _, puller) = WireValueChange();
|
||||
puller.PullAsync(default!, default!, default, default, default, default).ReturnsForAnyArgs(Array.Empty<AuditEvent>());
|
||||
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "2" }); // instance 5 lives on site 2
|
||||
|
||||
var result = await Runner().RunValueChangeAsync(_services.BuildServiceProvider(),
|
||||
new FlowDebugRunValueChangeCommand(5, "Status", "Running", WaitSeconds: 3), scoped, CancellationToken.None);
|
||||
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToSetAttributesAsync(default!, default!, default);
|
||||
Assert.Equal(FlowDebugRunner.NoTriggerCode, result.ErrorCode); // ran to the poll, not refused
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an administrator is never restricted by site scope.</summary>
|
||||
|
||||
Reference in New Issue
Block a user