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
@@ -38,6 +38,14 @@ public sealed class FlowDebugRunner
/// <summary>Error code when the method / instance / site does not exist.</summary>
public const string NotFoundCode = "NOT_FOUND";
/// <summary>
/// Arch-review F2: <see cref="InboundScriptExecutor"/>'s code for a routed call refused
/// because the target site is outside the operator's site scope. InboundAPI cannot
/// reference this assembly, so it raises its own <c>RouteSiteScopeException</c> and the
/// runner converts the code back to <see cref="SiteScopeViolationException"/>.
/// </summary>
public const string SiteScopeDeniedCode = "SITE_SCOPE_DENIED";
/// <summary>The <c>Extra.source</c> value stamped on rows this runner synthesizes.</summary>
public const string SourceTag = "flow-debugger";
@@ -103,7 +111,12 @@ public sealed class FlowDebugRunner
var sw = Stopwatch.StartNew();
var timeout = TimeSpan.FromSeconds(Math.Max(1, method.TimeoutSeconds));
var route = sp.GetRequiredService<RouteHelper>().WithTraceSink(events.Enqueue);
// Arch-review F2: a method Live run has no single target site to check up front the
// way RunValueChangeAsync does — the operator picks a METHOD, and where it routes is
// only known once the script runs. So the operator's scope is pushed into the
// RouteHelper and enforced at route time, before any site traffic.
var scope = await ResolvePermittedSiteIdentifiersAsync(sp, user, ct).ConfigureAwait(false);
var route = sp.GetRequiredService<RouteHelper>().WithTraceSink(events.Enqueue).WithSiteScope(scope);
var executor = sp.GetRequiredService<InboundScriptExecutor>();
InboundScriptResult result;
@@ -140,6 +153,16 @@ public sealed class FlowDebugRunner
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson());
await WriteCentralAsync(sp, inboundRow, ct).ConfigureAwait(false);
// Arch-review F2: convert the executor's code into the same authorization failure
// RunValueChangeAsync raises, so both Live entry points surface one exception type
// (→ ManagementUnauthorized). Deliberately AFTER the inbound row is written: a
// denied attempt must still be audited against the operator's name.
if (string.Equals(result.ErrorCode, SiteScopeDeniedCode, StringComparison.Ordinal))
{
throw new SiteScopeViolationException(
result.ErrorMessage ?? "Access denied: routed call outside your site scope.");
}
var ordered = new List<AuditEvent>(events.Count + 1) { inboundRow };
ordered.AddRange(events.OrderBy(e => e.OccurredAtUtc).ThenBy(e => e.EventId));
@@ -301,6 +324,35 @@ public sealed class FlowDebugRunner
}
}
/// <summary>
/// Arch-review F2: the operator's <c>PermittedSiteIds</c> — NUMERIC site ids as strings —
/// resolved to the site IDENTIFIERS <see cref="IInstanceLocator"/> speaks, for
/// <c>RouteHelper.WithSiteScope</c>. Returns null (unscoped) for an unscoped user or an
/// Administrator, exactly as <see cref="EnforceSiteScope"/> treats them.
///
/// <para>
/// A returned set may be EMPTY — a scoped user whose sites no longer exist routes nowhere.
/// That is the intended fail-closed outcome, and is why null and empty are not the same.
/// </para>
/// </summary>
private static async Task<IReadOnlySet<string>?> ResolvePermittedSiteIdentifiersAsync(
IServiceProvider sp, AuthenticatedUser user, CancellationToken ct)
{
if (user.PermittedSiteIds.Length == 0) return null;
if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return null;
var sites = sp.GetRequiredService<ISiteRepository>();
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in user.PermittedSiteIds)
{
if (!int.TryParse(raw, out var id)) continue;
var site = await sites.GetSiteByIdAsync(id, ct).ConfigureAwait(false);
if (site is not null) set.Add(site.SiteIdentifier);
}
return set;
}
private async Task WriteCentralAsync(IServiceProvider sp, AuditEvent evt, CancellationToken ct)
{
var writer = sp.GetService<ICentralAuditWriter>();