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
@@ -23,7 +23,9 @@
<label class="form-label form-label-sm">Entry point</label>
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
data-test="flow-entry-method" disabled="@(_running || _methodLiveRefused)"
title="@(_methodLiveRefused ? MethodLiveRefusedHint : null)"
@onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
</div>
@@ -128,6 +130,13 @@
</div>
</div>
@if (_methodLiveRefused && _mode == FlowMode.Live)
{
<div class="text-muted small mb-2" data-test="flow-method-live-refused">
@MethodLiveRefusedHint
</div>
}
@if (!_canLive)
{
<div class="text-muted small mb-2" data-test="flow-replay-only">
@@ -113,6 +113,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable
/// </summary>
private bool _canLive;
// Gitea #55 (owner decision (b)): a site-scoped Deployer may not Live-run an inbound API
// method — the method has no site, so its central-side effects cannot be bounded by the
// scope. The server refuses it (FlowDebugRunner); the page disables the entry and says why.
// Script entry (instance-scoped) and Replay stay available.
private bool _methodLiveRefused;
private const string MethodLiveRefusedHint =
"Live runs of an inbound API method are not available to a site-scoped Deployer: a method has no site, " +
"so its central-side effects (Database, ExternalSystem, notifications) cannot be bounded by your scope. " +
"Run a site script on a permitted instance, or use Replay.";
private List<ApiMethod> _methods = [];
private List<Site> _sites = [];
@@ -258,7 +269,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
_entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
_entry == EntryKind.Method ? !_methodLiveRefused && _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
: SelectedScript is { } s && (s.IsWriteKind
? !string.IsNullOrWhiteSpace(_valueChangeValue)
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
@@ -304,6 +315,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
if (!_canLive)
_mode = FlowMode.Replay;
_methodLiveRefused = _canLive && !await SiteScope.IsSystemWideAsync();
if (_methodLiveRefused)
_entry = EntryKind.Script;
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
@@ -339,7 +353,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
}
}
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
else if (!_methodLiveRefused && MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
{
_entry = EntryKind.Method;
_selectedMethodId = mid;
@@ -390,7 +404,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
}
}
}
else
else if (!_methodLiveRefused)
{
var method = await JS.InvokeAsync<string?>("localStorage.getItem", StorageMethod);
if (int.TryParse(method, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mid) && _methods.Any(m => m.Id == mid))
@@ -411,6 +425,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
private async Task SetEntryAsync(EntryKind entry)
{
if (_entry == entry) return;
if (entry == EntryKind.Method && _methodLiveRefused) return;
_entry = entry;
ResetResult();
_predicted = null;