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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,87 @@ public class FlowDebugRunnerTests
|
||||
Assert.Equal(FlowDebugRunner.NotFoundCode, empty.ErrorCode);
|
||||
}
|
||||
|
||||
// --- Arch-review F2: a method-entry Live run is bound by the operator's site scope ---
|
||||
//
|
||||
// RunValueChange already called EnforceSiteScope on the target instance; a method
|
||||
// Live run has no single target site up front, so the scope is pushed into the
|
||||
// RouteHelper and enforced at route time (RouteTarget.ResolveSiteAsync), before any
|
||||
// site traffic. The denial comes back as SITE_SCOPE_DENIED and the runner converts it.
|
||||
|
||||
/// <summary>Wires a method whose script routes to <paramref name="instanceCode"/>, resident at <paramref name="siteIdentifier"/>.</summary>
|
||||
private ISiteRepository WireRoutedMethod(int methodId, string instanceCode, string siteIdentifier)
|
||||
{
|
||||
var method = new ApiMethod("M", "x") { Id = methodId, TimeoutSeconds = 5 };
|
||||
_apiRepo.GetApiMethodByIdAsync(methodId, Arg.Any<CancellationToken>()).Returns(method);
|
||||
_locator.GetSiteIdForInstanceAsync(instanceCode, Arg.Any<CancellationToken>()).Returns(siteIdentifier);
|
||||
_router.RouteToCallAsync(siteIdentifier, Arg.Any<RouteToCallRequest>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => new RouteToCallResponse(
|
||||
((RouteToCallRequest)ci[1]).CorrelationId, true, 7, null, DateTimeOffset.UtcNow));
|
||||
_executor.RegisterHandler("M", ctx => ctx.Route.To(instanceCode).Call("work"));
|
||||
|
||||
// The user's PermittedSiteIds are NUMERIC ids; the locator speaks site IDENTIFIERS,
|
||||
// so the runner resolves one to the other through the repository.
|
||||
var sites = Substitute.For<ISiteRepository>();
|
||||
sites.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(new Site("Site A", "site-a") { Id = 1 });
|
||||
_services.AddSingleton(sites);
|
||||
return sites;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteToOtherSite_IsUnauthorized_AndInboundRowIsWritten()
|
||||
{
|
||||
WireRoutedMethod(11, "inst", "site-b");
|
||||
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
|
||||
|
||||
await Assert.ThrowsAsync<SiteScopeViolationException>(() => Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(11, null), scoped, CancellationToken.None));
|
||||
|
||||
// The site was never contacted...
|
||||
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");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_ScopedDeployer_RouteWithinScope_Runs()
|
||||
{
|
||||
// The same scoped user, routing to a site their numeric id DOES resolve to.
|
||||
WireRoutedMethod(12, "inst", "site-a");
|
||||
var scoped = new AuthenticatedUser("ops", "Ops", new[] { "Deployer" }, new[] { "1" });
|
||||
|
||||
var result = await Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(12, null), scoped, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_Administrator_IsNeverScoped()
|
||||
{
|
||||
WireRoutedMethod(13, "inst", "site-b");
|
||||
var admin = new AuthenticatedUser("boss", "Boss", new[] { "Deployer", "Administrator" }, new[] { "1" });
|
||||
|
||||
var result = await Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(13, null), admin, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunMethod_UnscopedUser_IsNeverScoped()
|
||||
{
|
||||
WireRoutedMethod(14, "inst", "site-b");
|
||||
|
||||
// Deployer with an EMPTY PermittedSiteIds = global scope, as EnforceSiteScope treats it.
|
||||
var result = await Runner().RunMethodAsync(
|
||||
_services.BuildServiceProvider(), new FlowDebugRunMethodCommand(14, null), Deployer, CancellationToken.None);
|
||||
|
||||
Assert.True(result.Completed, result.Error);
|
||||
await _router.ReceivedWithAnyArgs(1).RouteToCallAsync(default!, default!, default);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoleGate_AllThreeCommands_AreDeployerOnly()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user