fix(centralui): bound script-analysis payload/concurrency; bound Test Run needs Deployer, site scope and audit row (review 2026-10-21 07/P-4, 07/C-2)

This commit is contained in:
Joseph Doherty
2026-10-22 08:44:26 -04:00
parent 6644cf917d
commit 5c8f6ff7aa
4 changed files with 465 additions and 1 deletions
@@ -0,0 +1,112 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Debugging;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Policy for a Test Run bound to a deployed instance (review 2026-10-21 07/C-2). A bound run
/// performs REAL tag writes / script calls / waits against a site, so — like the Flow Debugger —
/// it requires the Deployer role (an unbound Test Run stays Designer-only), is confined to the
/// operator's permitted sites, and leaves an operator-attributed audit row.
/// </summary>
public static class SandboxBoundRunPolicy
{
/// <summary>The <see cref="ScriptTraceExtra.Source"/> tag stamped on the audit row.</summary>
public const string SourceTag = "test-run";
/// <summary>True when the request binds a deployed instance (and so performs real site operations).</summary>
/// <param name="request">The run request.</param>
/// <returns>Whether the run is bound to an instance.</returns>
public static bool IsBound(SandboxRunRequest request) =>
request.Kind != ScriptKind.InboundApi && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName);
/// <summary>Checks role and site scope for a bound run; a null result means the run may proceed.</summary>
/// <param name="request">The run request.</param>
/// <param name="user">The authenticated caller.</param>
/// <param name="authorization">Policy evaluator for the Deployer requirement.</param>
/// <param name="locator">Resolves an instance's site identifier.</param>
/// <param name="sites">Maps the site identifier to its numeric id for the scope check.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A 403 result when denied; otherwise <see langword="null"/>.</returns>
public static async Task<IResult?> AuthorizeAsync(
SandboxRunRequest request, ClaimsPrincipal user, IAuthorizationService authorization,
IInstanceLocator? locator, ISiteRepository? sites, CancellationToken ct)
{
if (!IsBound(request)) return null;
var allowed = await authorization.AuthorizeAsync(user, AuthorizationPolicies.RequireDeployment);
if (!allowed.Succeeded)
{
return Denied("Running a script bound to a deployed instance requires the Deployer role.");
}
var (systemWide, permitted) = SiteScopeService.Resolve(user);
if (systemWide) return null;
// A scoped operator must be able to prove the instance's site is theirs; an unknown
// instance or an unresolvable site is refused rather than waved through.
var siteIdentifier = locator is null
? null
: await locator.GetSiteIdForInstanceAsync(request.BindInstanceUniqueName!, ct);
var site = siteIdentifier is null || sites is null
? null
: (await sites.GetAllSitesAsync(ct)).FirstOrDefault(s => s.SiteIdentifier == siteIdentifier);
if (site is null || !permitted.Contains(site.Id))
{
return Denied("You are not permitted to run scripts against instances at that site.");
}
return null;
}
/// <summary>
/// Writes one operator-attributed row for a bound run (best-effort: a failure is logged and
/// never fails the run, which has already had its real side effects).
/// </summary>
/// <param name="request">The run request.</param>
/// <param name="result">The run outcome.</param>
/// <param name="username">The operator.</param>
/// <param name="startedAtUtc">When the run started.</param>
/// <param name="writer">Central audit writer, or null when none is registered.</param>
/// <param name="nodeIdentity">Stamps <c>SourceNode</c>.</param>
/// <param name="logger">Failure logger.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public static async Task AuditAsync(
SandboxRunRequest request, SandboxRunResult result, string username, DateTime startedAtUtc,
ICentralAuditWriter? writer, INodeIdentityProvider? nodeIdentity, ILogger logger, CancellationToken ct)
{
if (writer is null) return;
try
{
var row = ScadaBridgeAuditEventFactory.Create(
AuditChannel.ScriptTrace, AuditKind.RouteCall,
result.Success ? AuditStatus.Delivered : AuditStatus.Failed,
occurredAtUtc: startedAtUtc,
actor: username,
target: request.BindInstanceUniqueName,
sourceNode: nodeIdentity?.NodeName,
executionId: Guid.NewGuid(),
sourceInstanceId: request.BindInstanceUniqueName,
durationMs: (int)Math.Min(result.DurationMs, int.MaxValue),
errorMessage: result.Error,
extra: new ScriptTraceExtra { Source = SourceTag }.ToJson());
await writer.WriteAsync(row, ct);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "Test Run audit write failed for instance {Instance}", request.BindInstanceUniqueName);
}
}
private static IResult Denied(string message) =>
Results.Json(new { error = message, code = "FORBIDDEN" }, statusCode: StatusCodes.Status403Forbidden);
}
@@ -1,7 +1,12 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
@@ -19,6 +24,31 @@ public static class ScriptAnalysisEndpoints
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AuthorizationPolicies.RequireDesign);
// Review 2026-10-21 07/P-4: every call compiles fresh, so bound the payload and the
// per-user concurrency. Oversized -> 413, over the concurrency ceiling -> 429.
group.AddEndpointFilter(async (ctx, next) =>
{
var http = ctx.HttpContext;
if (http.Request.ContentLength is > ScriptAnalysisRequestGuard.MaxBodyBytes)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
foreach (var arg in ctx.Arguments)
{
if (ScriptAnalysisRequestGuard.CodeLength(arg) is > ScriptAnalysisRequestGuard.MaxCodeChars)
{
return Results.StatusCode(StatusCodes.Status413PayloadTooLarge);
}
}
using var lease = ScriptAnalysisRequestGuard.Shared.TryEnter(http.User.GetUsername());
if (lease is null)
{
return Results.StatusCode(StatusCodes.Status429TooManyRequests);
}
return await next(ctx);
});
group.MapPost("/diagnostics", (DiagnoseRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.Diagnose(req)));
@@ -38,7 +68,27 @@ public static class ScriptAnalysisEndpoints
Results.Ok(svc.InlayHints(req)));
group.MapPost("/run", async (SandboxRunRequest req, ScriptAnalysisService svc, HttpContext http) =>
Results.Ok(await svc.RunInSandboxAsync(req, http.RequestAborted)));
{
// Review 2026-10-21 07/C-2: a run bound to a deployed instance does real site
// operations — Deployer role, site scope, and an operator-attributed audit row.
var sp = http.RequestServices;
var denied = await SandboxBoundRunPolicy.AuthorizeAsync(
req, http.User, sp.GetRequiredService<IAuthorizationService>(),
sp.GetService<IInstanceLocator>(), sp.GetService<ISiteRepository>(), http.RequestAborted);
if (denied is not null) return denied;
var startedAt = DateTime.UtcNow;
var result = await svc.RunInSandboxAsync(req, http.RequestAborted);
if (SandboxBoundRunPolicy.IsBound(req))
{
await SandboxBoundRunPolicy.AuditAsync(
req, result, http.User.GetUsername(), startedAt,
sp.GetService<ICentralAuditWriter>(), sp.GetService<INodeIdentityProvider>(),
sp.GetRequiredService<ILoggerFactory>().CreateLogger(typeof(SandboxBoundRunPolicy)),
CancellationToken.None);
}
return Results.Ok(result);
});
return endpoints;
}
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Abuse bounds for the Roslyn-backed script-analysis endpoints (review 2026-10-21 07/P-4):
/// every request does a fresh compilation, so an unbounded <c>Code</c> or an unthrottled
/// caller can pin cores. Monaco's 500 ms debounce is the only other throttle.
/// </summary>
public sealed class ScriptAnalysisRequestGuard
{
/// <summary>Maximum accepted script text length, in characters (256 KB).</summary>
public const int MaxCodeChars = 256 * 1024;
/// <summary>Maximum accepted request body, in bytes (whole payload incl. editor context).</summary>
public const long MaxBodyBytes = 1024 * 1024;
/// <summary>Maximum concurrently executing analysis requests per user.</summary>
public const int MaxConcurrentPerUser = 6;
private readonly object _lock = new();
private readonly Dictionary<string, int> _inFlight = new(StringComparer.OrdinalIgnoreCase);
private readonly int _maxConcurrent;
/// <summary>Initializes the guard.</summary>
/// <param name="maxConcurrentPerUser">Per-user concurrency ceiling.</param>
public ScriptAnalysisRequestGuard(int maxConcurrentPerUser = MaxConcurrentPerUser)
{
_maxConcurrent = maxConcurrentPerUser;
}
/// <summary>The process-wide instance used by the endpoint group.</summary>
public static ScriptAnalysisRequestGuard Shared { get; } = new();
/// <summary>
/// Tries to start a request for <paramref name="user"/>. Entries exist only while a request
/// is in flight, so the map is bounded by concurrent requests rather than by user count.
/// </summary>
/// <param name="user">The caller's identity key.</param>
/// <returns>A lease to dispose on completion, or <see langword="null"/> when over the ceiling.</returns>
public IDisposable? TryEnter(string user)
{
lock (_lock)
{
_inFlight.TryGetValue(user, out var n);
if (n >= _maxConcurrent) return null;
_inFlight[user] = n + 1;
}
return new Lease(this, user);
}
/// <summary>The script text carried by a request DTO, or null for a type without one.</summary>
/// <param name="request">The bound request argument.</param>
/// <returns>The code length in characters, or null when the argument carries no code.</returns>
public static int? CodeLength(object? request) => request switch
{
DiagnoseRequest r => r.Code?.Length,
CompletionsRequest r => r.CodeText?.Length,
HoverRequest r => r.CodeText?.Length,
SignatureHelpRequest r => r.CodeText?.Length,
FormatRequest r => r.Code?.Length,
InlayHintsRequest r => r.Code?.Length,
SandboxRunRequest r => r.Code?.Length,
_ => null,
};
private void Exit(string user)
{
lock (_lock)
{
if (!_inFlight.TryGetValue(user, out var n)) return;
if (n <= 1) _inFlight.Remove(user); else _inFlight[user] = n - 1;
}
}
private sealed class Lease(ScriptAnalysisRequestGuard owner, string user) : IDisposable
{
private int _disposed;
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) == 0) owner.Exit(user);
}
}
}