fix(inbound-api): Flow Debugger method-entry Live run enforces the operator's site scope at route time (arch-review F2)

A method-entry Live run had no site-scope check at all. RunValueChangeAsync
calls EnforceSiteScope on its named instance, but a method run has no single
target site up front — the operator picks a METHOD, and where its script routes
is only known once it runs — so a site-scoped Deployer could reach any site the
method happened to route to.

The scope is therefore pushed into RouteHelper and enforced at route time:

- RouteHelper.WithSiteScope(IReadOnlySet<string>?) carries a set of site
  IDENTIFIERS (null = unscoped, the state of every ordinary inbound HTTP
  request; an EMPTY set permits nothing, the fail-closed outcome for a scoped
  user whose sites no longer exist). Threaded through every With* copy and To().
- The gate lives in RouteTarget.ResolveSiteAsync — the single choke point that
  Call, GetAttributes, WaitForAttribute and SetAttributes all pass through — so
  it fires BEFORE any site traffic and throws the new RouteSiteScopeException.
- InboundScriptExecutor maps it to error code SITE_SCOPE_DENIED. The type is
  InboundAPI's own (that assembly cannot reference ManagementService), and it
  derives from InvalidOperationException so base-type callers are unaffected.
- FlowDebugRunner.RunMethodAsync resolves the user's PermittedSiteIds (numeric)
  to identifiers via ISiteRepository and converts SITE_SCOPE_DENIED back to
  SiteScopeViolationException (-> ManagementUnauthorized), AFTER writing the
  inbound audit row so a denied attempt is still recorded against the operator.

Administrators and unscoped users get a null scope and are unaffected, exactly
as EnforceSiteScope treats them. Every contract change is a trailing optional
parameter. Mutation-checked: disabling the gate fails 3 of the 5 new RouteHelper
tests and 1 of the runner tests, while the in-scope/unscoped positives still pass.

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 20a05941ee
5 changed files with 317 additions and 10 deletions
@@ -506,6 +506,23 @@ public class InboundScriptExecutor
_logger.LogDebug("Inbound API request for method {Method} cancelled by client", method.Name);
return new InboundScriptResult(false, null, "Request cancelled by client");
}
catch (RouteSiteScopeException ex)
{
// Arch-review F2: the script routed to a site outside the caller's site
// scope. RouteTarget.ResolveSiteAsync refused BEFORE any site traffic, so
// the script never saw a routed reply — there is nothing to roll back.
// The message is the operator-facing denial, safe to surface verbatim
// (it names only the instance and its site), unlike the generic
// SCRIPT_ERROR path which deliberately hides internals.
//
// Ordered before the SiteUnreachableException catch purely for
// readability: the two types are siblings under InvalidOperationException,
// so neither shadows the other.
_logger.LogWarning(
"API method {Method} refused a routed call outside the caller's site scope: {Message}",
method.Name, ex.Message);
return new InboundScriptResult(false, null, ex.Message, "SITE_SCOPE_DENIED");
}
catch (SiteUnreachableException)
{
// C3: a routed Route.To(...).Call(...) reached a site that could not be