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