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:
@@ -52,7 +52,7 @@ public static class FlowDebugCommands
|
||||
DefaultValueFactory = _ => 120,
|
||||
};
|
||||
var showLogsOption = new Option<bool>("--show-logs") { Description = "Print each step's ScriptLog lines and exception detail as indented lines under its row (table output only)" };
|
||||
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps" };
|
||||
var cmd = new Command("run-method") { Description = "Run an inbound API method on the active central node with full tracing and print the flow's steps (system-wide Deployer or Administrator; refused for a site-scoped user — use replay or run-script)" };
|
||||
cmd.Add(methodIdOption);
|
||||
cmd.Add(paramsOption);
|
||||
cmd.Add(timeoutOption);
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
<label class="form-label form-label-sm">Entry point</label>
|
||||
<div class="btn-group btn-group-sm d-block" role="group" aria-label="Entry point">
|
||||
<button type="button" class="btn @(_entry == EntryKind.Method ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-method" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
data-test="flow-entry-method" disabled="@(_running || _methodLiveRefused)"
|
||||
title="@(_methodLiveRefused ? MethodLiveRefusedHint : null)"
|
||||
@onclick="() => SetEntryAsync(EntryKind.Method)">Inbound API method</button>
|
||||
<button type="button" class="btn @(_entry == EntryKind.Script ? "btn-primary" : "btn-outline-secondary")"
|
||||
data-test="flow-entry-script" disabled="@_running" @onclick="() => SetEntryAsync(EntryKind.Script)">Site script</button>
|
||||
</div>
|
||||
@@ -128,6 +130,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (_methodLiveRefused && _mode == FlowMode.Live)
|
||||
{
|
||||
<div class="text-muted small mb-2" data-test="flow-method-live-refused">
|
||||
@MethodLiveRefusedHint
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!_canLive)
|
||||
{
|
||||
<div class="text-muted small mb-2" data-test="flow-replay-only">
|
||||
|
||||
+18
-3
@@ -113,6 +113,17 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
/// </summary>
|
||||
private bool _canLive;
|
||||
|
||||
// Gitea #55 (owner decision (b)): a site-scoped Deployer may not Live-run an inbound API
|
||||
// method — the method has no site, so its central-side effects cannot be bounded by the
|
||||
// scope. The server refuses it (FlowDebugRunner); the page disables the entry and says why.
|
||||
// Script entry (instance-scoped) and Replay stay available.
|
||||
private bool _methodLiveRefused;
|
||||
|
||||
private const string MethodLiveRefusedHint =
|
||||
"Live runs of an inbound API method are not available to a site-scoped Deployer: a method has no site, " +
|
||||
"so its central-side effects (Database, ExternalSystem, notifications) cannot be bounded by your scope. " +
|
||||
"Run a site script on a permitted instance, or use Replay.";
|
||||
|
||||
private List<ApiMethod> _methods = [];
|
||||
private List<Site> _sites = [];
|
||||
|
||||
@@ -258,7 +269,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
// Script entry: a write kind needs the value to write; a call kind needs a parameter form
|
||||
// that parses (a script declaring no parameters is runnable as soon as it is chosen).
|
||||
private bool CanRun => _canLive && !_running && _mode == FlowMode.Live && (
|
||||
_entry == EntryKind.Method ? _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
|
||||
_entry == EntryKind.Method ? !_methodLiveRefused && _selectedMethodId > 0 && (_inputFields.Count == 0 || _methodParamsValid)
|
||||
: SelectedScript is { } s && (s.IsWriteKind
|
||||
? !string.IsNullOrWhiteSpace(_valueChangeValue)
|
||||
: string.IsNullOrWhiteSpace(s.ParameterDefinitions) || _scriptParamsValid));
|
||||
@@ -304,6 +315,9 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
.HasClaim(JwtTokenService.RoleClaimType, Roles.Deployer);
|
||||
if (!_canLive)
|
||||
_mode = FlowMode.Replay;
|
||||
_methodLiveRefused = _canLive && !await SiteScope.IsSystemWideAsync();
|
||||
if (_methodLiveRefused)
|
||||
_entry = EntryKind.Script;
|
||||
|
||||
_methods = (await InboundApi.GetAllApiMethodsAsync()).OrderBy(m => m.Name, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
@@ -339,7 +353,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
|
||||
else if (!_methodLiveRefused && MethodId is int mid && mid > 0 && _methods.Any(m => m.Id == mid))
|
||||
{
|
||||
_entry = EntryKind.Method;
|
||||
_selectedMethodId = mid;
|
||||
@@ -390,7 +404,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (!_methodLiveRefused)
|
||||
{
|
||||
var method = await JS.InvokeAsync<string?>("localStorage.getItem", StorageMethod);
|
||||
if (int.TryParse(method, NumberStyles.Integer, CultureInfo.InvariantCulture, out var mid) && _methods.Any(m => m.Id == mid))
|
||||
@@ -411,6 +425,7 @@ public partial class FlowDebugger : ComponentBase, IDisposable
|
||||
private async Task SetEntryAsync(EntryKind entry)
|
||||
{
|
||||
if (_entry == entry) return;
|
||||
if (entry == EntryKind.Method && _methodLiveRefused) return;
|
||||
_entry = entry;
|
||||
ResetResult();
|
||||
_predicted = null;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user