fix(flow-debugger): refuse method-entry Live runs for site-scoped users (#55)

Owner decision (b), 2026-11-09. An inbound API method has no site
affinity, so the F2 route-time scope only bounded routed SITE traffic;
the method's central-side effects (Database.*, ExternalSystem,
notifications) still ran for a scoped Deployer. FlowDebugRunner now
refuses FlowDebugRunMethodCommand for a site-scoped caller (site ids,
not Administrator) before the script executes, audits the refusal as a
failed InboundRequest under the operator's name, and raises
SiteScopeViolationException -> ManagementUnauthorized, so the Central
UI and the CLI (same /management command) share the gate.

The page disables the "Inbound API method" entry for scoped Deployers
with the reason (tooltip + inline note), starts them on script entry,
and ignores ?methodId= / a stored method choice. Replay and the
instance-scoped script / value-change Live runs are unchanged. The
route-time scope is kept as defence in depth.

Tests: scoped refused (in- and out-of-scope routing; script never runs,
refusal audited), unscoped/Administrator allowed, scoped Replay allowed,
scoped in-scope value-change Live still runs; bUnit for the disabled
entry, the drill-in, and the unscoped control. Mutation-proven (gate
and UI flag reverted -> 4 red).

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 22:22:44 -05:00
parent 4624c8db28
commit 63ec45e2e3
11 changed files with 198 additions and 25 deletions
@@ -151,6 +151,27 @@ public sealed class FlowDebugRunner
var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId, ct).ConfigureAwait(false)
?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.", NotFoundCode);
// Gitea #55 (owner decision (b), 2026-11-09): a site-scoped operator may not Live-run a
// method at all. ApiMethods have no site affinity, so the route-time scope below (F2)
// only bounds routed SITE traffic — the method's central-side effects (Database.*,
// ExternalSystem, notifications) would still run for another plant. Refused before
// anything executes; Replay and the instance-scoped script / value-change Live runs
// stay available to them. The refusal is audited against the operator's name.
if (IsSiteScoped(user))
{
var refusal = MethodLiveRefusedMessage(user);
await WriteCentralAsync(sp, ScadaBridgeAuditEventFactory.Create(
AuditChannel.ApiInbound, AuditKind.InboundRequest, AuditStatus.Failed,
occurredAtUtc: DateTime.UtcNow,
actor: user.Username,
target: method.Name,
sourceNode: sp.GetService<INodeIdentityProvider>()?.NodeName,
executionId: Guid.NewGuid(),
errorMessage: refusal,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson()), ct).ConfigureAwait(false);
throw new SiteScopeViolationException(refusal);
}
var resolveRef = await SchemaRefResolver.BuildAsync(
sp.GetService<ISharedSchemaRepository>(), [method.ParameterDefinitions], ct).ConfigureAwait(false);
var validation = ParameterValidator.Validate(cmd.Parameters, method.ParameterDefinitions, resolveRef);
@@ -173,7 +194,9 @@ public sealed class FlowDebugRunner
// 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.
// RouteHelper and enforced at route time, before any site traffic. Since Gitea #55 a
// scoped operator is refused above, so this resolves to null (unscoped) for every
// caller that reaches it; it is kept as defence in depth should that gate ever loosen.
var scope = await ResolvePermittedSiteIdentifiersAsync(sp, user, ct).ConfigureAwait(false);
// Gitea #54: a routed site run whose own collector dropped rows reports it here; the flags
// are OR-ed into the result so the page labels the short site lane as truncated.
@@ -708,6 +731,31 @@ public sealed class FlowDebugRunner
}
}
/// <summary>
/// True when <paramref name="user"/> is restricted to a site subset — carries site ids and
/// is not an Administrator — by the same rule <see cref="EnforceSiteScope"/> applies.
/// </summary>
/// <param name="user">The operator.</param>
/// <returns><c>true</c> for a site-scoped operator.</returns>
public static bool IsSiteScoped(AuthenticatedUser user)
{
ArgumentNullException.ThrowIfNull(user);
return user.PermittedSiteIds.Length > 0
&& !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase);
}
/// <summary>The refusal a site-scoped operator gets for a method-entry Live run (Gitea #55).</summary>
/// <param name="user">The site-scoped operator.</param>
/// <returns>The operator-facing refusal message.</returns>
public static string MethodLiveRefusedMessage(AuthenticatedUser user)
{
ArgumentNullException.ThrowIfNull(user);
return $"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}], " +
"and an inbound API method has no site, so its central-side effects (Database, ExternalSystem, " +
"notifications) cannot be bounded by your scope. Live method runs require a system-wide Deployer " +
"or an Administrator. Replay, and Live runs of a site script on a permitted instance, remain available.";
}
private static void EnforceSiteScope(AuthenticatedUser user, int siteId)
{
if (user.PermittedSiteIds.Length == 0) return;