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
@@ -621,4 +621,86 @@ public class RouteHelperTests
await Assert.ThrowsAsync<SiteUnreachableException>(
() => CreateHelper().To("inst").GetAttributes(new[] { "a" }));
}
// --- Arch-review F2: site scope on routed calls ---
//
// The gate lives in RouteTarget.ResolveSiteAsync — the single choke point every
// routed operation passes through — so it fires BEFORE any site traffic.
private static IReadOnlySet<string> Scope(params string[] siteIdentifiers) =>
new HashSet<string>(siteIdentifiers, StringComparer.OrdinalIgnoreCase);
[Fact]
public async Task Call_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
{
SiteResolves("inst-1", "site-b");
var helper = CreateHelper().WithSiteScope(Scope("site-a"));
var ex = await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").Call("doWork"));
Assert.Contains("site-b", ex.Message);
Assert.Contains("inst-1", ex.Message);
// Nothing was routed: the check runs before the site is contacted.
await _router.DidNotReceiveWithAnyArgs().RouteToCallAsync(default!, default!, default);
}
[Fact]
public async Task Call_InsideSiteScope_Routes()
{
SiteResolves("inst-1", "site-b");
_router.RouteToCallAsync("site-b", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow));
var helper = CreateHelper().WithSiteScope(Scope("SITE-B"));
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
}
[Fact]
public async Task Call_NullScope_IsUnscoped()
{
SiteResolves("inst-1", "site-b");
_router.RouteToCallAsync("site-b", Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
.Returns(ci => new RouteToCallResponse(
((RouteToCallRequest)ci[1]).CorrelationId, true, 42, null, DateTimeOffset.UtcNow));
var helper = CreateHelper().WithSiteScope(null);
Assert.Equal(42, await helper.To("inst-1").Call("doWork"));
}
[Fact]
public async Task SetAttributes_OutsideSiteScope_ThrowsRouteSiteScopeException_BeforeRouting()
{
// ResolveSiteAsync is shared by all four routed operations, so the gate is
// not Call-specific.
SiteResolves("inst-1", "site-b");
var helper = CreateHelper().WithSiteScope(Scope("site-a"));
await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").SetAttributes(new Dictionary<string, string> { ["x"] = "1" }));
await _router.DidNotReceiveWithAnyArgs().RouteToSetAttributesAsync(default!, default!, default);
}
[Fact]
public async Task WithSiteScope_SurvivesTheOtherWithCopies()
{
// The scope must not be dropped by the copies InboundScriptExecutor makes
// (WithDeadline / WithRequestAborted / WithParentExecutionId / WithTrace).
SiteResolves("inst-1", "site-b");
var helper = CreateHelper()
.WithSiteScope(Scope("site-a"))
.WithDeadline(CancellationToken.None)
.WithRequestAborted(CancellationToken.None)
.WithParentExecutionId(Guid.NewGuid())
.WithTrace(true)
.WithTraceSink(null);
await Assert.ThrowsAsync<RouteSiteScopeException>(
() => helper.To("inst-1").Call("doWork"));
}
}