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