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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user