Resolve all 33 medium-severity architecture review findings

Second slice of the 2026-09-09 whole-system review remediation
(docs/plans/2026-09-09-arch-review.md §10), on top of the High slice 146a1323.

Runtime/devices: SR-2 alarm on-trigger integration scope; SR-3 correlated NativeAlarmActor
subscribe retry; SR-4 immutable audited DB target; SR-5 ordered InstanceActor stop/flush;
SR-12 rehydration fence; SR-13 S&F gate recheck per delivery; DC-4 no OPC UA security
downgrade; DC-5 alarm snapshot for every new subscriber; ORCH-1 sync-dispose joins the pump.

Management/UI: MG-2 site scope on audit tree + Flow Debugger Replay; MG-3 stale-probe memo
removed; MG-4 server-owned bulk-deploy deadline; UI-5 sandbox runs in a killable child
process (new CentralUI.SandboxWorker project); UI-14 bounded sandbox output/IPC.

Edge/audit: EI-1 relay audit ownership; EI-2 SkipBodyCapture suppresses error text; EI-3/EI-11
error-field redaction + caps; EI-4 single relay deadline, pre-execution-only retry; EI-5
authenticated relay provenance; EI-10 body cap before audit read.

Persistence/contracts: DL-1 global Instance.UniqueName unique index + conflict translation;
DL-2 DataConnection protocol config encrypted at rest (migration
20260909203046_EnforceGlobalInstanceNamesAndProtectProtocolConfiguration — coordinated
central upgrade, old binaries cannot read protected rows); DL-3 bounded Replay paging;
DL-6 cross-site Overwrite of a deployed instance rejected; XC-4 proto descriptor contract test.

Config/hygiene: XC-1 central listener isolation by local port; XC-2/XC-3 inert SMTP + cron
keys removed from rig settings; CM-2 env2 bootstrap guard enabled; DC-6 deploy site exit code;
DC-7 notifier retries only explicit standby rejection; XC-5 ClusterClient purged from docs.

Gates recorded in the report: build 0/0, 9 397 passed / 0 failed, rig redeployed on all
eight nodes with the migration applied. 46 Low findings remain open.
This commit is contained in:
Joseph Doherty
2026-09-09 22:05:30 -04:00
parent 146a13238f
commit 9a87d7a328
136 changed files with 5441 additions and 648 deletions
@@ -0,0 +1,45 @@
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>Thread-safe console sink that never retains more than its character budget.</summary>
public sealed class BoundedSandboxWriter(int capacity) : StringWriter
{
private readonly StringBuilder _retained = new(Math.Max(0, capacity));
private readonly object _gate = new();
private long _dropped;
public int RetainedCharacters { get { lock (_gate) return _retained.Length; } }
public long DroppedCharacters { get { lock (_gate) return _dropped; } }
public override void Write(char value) { Span<char> one = stackalloc char[1]; one[0] = value; Write(one); }
public override void Write(string? value) { if (value is not null) Write(value.AsSpan()); }
public override void Write(char[] buffer, int index, int count) => Write(buffer.AsSpan(index, count));
public override void Write(ReadOnlySpan<char> buffer)
{
lock (_gate)
{
var take = Math.Min(buffer.Length, Math.Max(0, capacity - _retained.Length));
_retained.Append(buffer[..take]);
_dropped = Math.Min(long.MaxValue - (buffer.Length - take), _dropped) + buffer.Length - take;
}
}
public override void WriteLine(string? value) { lock (_gate) { Write(value); Write(NewLine); } }
public override void WriteLine() => Write(NewLine);
public override string ToString() { lock (_gate) return _retained.ToString() + (_dropped > 0 ? "\n… (truncated)" : ""); }
}
/// <summary>Rejects serialized data once its byte budget is reached, without retaining the excess.</summary>
public sealed class BoundedSandboxStream(int capacity) : MemoryStream
{
public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count));
public override void Write(ReadOnlySpan<byte> buffer)
{
if (Length + buffer.Length > capacity) throw new InvalidDataException("Sandbox output exceeds its byte budget.");
var bounded = buffer.ToArray();
base.Write(bounded, 0, bounded.Length);
}
public override void WriteByte(byte value) { Span<byte> one = stackalloc byte[1]; one[0] = value; Write(one); }
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer.Span); return ValueTask.CompletedTask; }
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{ cancellationToken.ThrowIfCancellationRequested(); Write(buffer, offset, count); return Task.CompletedTask; }
}
@@ -0,0 +1,224 @@
using System.Buffers.Binary;
using System.Data.Common;
using System.Diagnostics;
using System.Text.Json;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>Bounded private IPC for disposable, killable Test Run processes.</summary>
public static class SandboxWorkerProtocol
{
private const int FrameLimit = 1024 * 1024;
private static readonly SemaphoreSlim Admission = new(4, 4);
private static readonly JsonSerializerOptions Json = new() { IgnoreReadOnlyProperties = true };
private sealed record Frame(string Kind, string? Method, JsonElement Data, string? Error = null);
private sealed record Start(SandboxRunRequest Request, bool External, bool Database, bool Bound);
private sealed record ConnectionInfo(string Provider, string ConnectionString, bool Open);
/// <summary>Runs one child under a deadline; a slot remains charged until confirmed process exit.</summary>
public static async Task<SandboxRunResult> RunAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller)
=> await RunCoreAsync(request, services, catalog, caller);
internal static async Task<SandboxRunResult> RunCoreAsync(SandboxRunRequest request, IServiceProvider services,
ISharedScriptCatalog catalog, CancellationToken caller, Action<int>? started = null)
{
var seconds = Math.Clamp(request.TimeoutSeconds ?? 5, 1, 10);
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(caller);
deadline.CancelAfter(TimeSpan.FromSeconds(seconds));
var clock = Stopwatch.StartNew();
var acquired = false;
Process? process = null;
Task? stderr = null;
CancellationTokenRegistration terminateOnDeadline = default;
try
{
await Admission.WaitAsync(deadline.Token); acquired = true;
ISandboxInstanceGateway? gateway = null;
if (request.Kind != ScriptKind.InboundApi && !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
{
var locator = services.GetService<IInstanceLocator>();
var comms = services.GetService<CommunicationService>();
var site = locator is null ? null : await locator.GetSiteIdForInstanceAsync(request.BindInstanceUniqueName, deadline.Token);
if (site is null || comms is null) throw new ScriptSandboxException("Cannot bind instance: cross-site communication or deployment is unavailable.");
gateway = new SandboxInstanceGateway(comms, site, request.BindInstanceUniqueName, deadline.Token);
}
var worker = Path.Combine(AppContext.BaseDirectory, "ZB.MOM.WW.ScadaBridge.CentralUI.SandboxWorker.dll");
if (!File.Exists(worker)) throw new InvalidOperationException("Sandbox worker is not installed.");
var start = new ProcessStartInfo("dotnet") { UseShellExecute = false, RedirectStandardInput = true,
RedirectStandardOutput = true, RedirectStandardError = true, CreateNoWindow = true };
start.ArgumentList.Add(worker);
// A finite heap bounds accidental allocation in return serialization and script code.
start.Environment["DOTNET_GCHeapHardLimit"] = "20000000"; // 512 MiB, hexadecimal runtime setting.
process = Process.Start(start) ?? throw new InvalidOperationException("Could not start sandbox worker.");
terminateOnDeadline = deadline.Token.Register(static state =>
{
var child = (Process)state!;
try { if (!child.HasExited) child.Kill(entireProcessTree: true); }
catch (InvalidOperationException) { /* Concurrent normal process exit. */ }
}, process);
started?.Invoke(process.Id);
stderr = DrainAsync(process.StandardError.BaseStream);
await WriteAsync(process.StandardInput.BaseStream, new Frame("start", null,
Encode(new Start(request, services.GetService<IExternalSystemClient>() is not null,
services.GetService<IDatabaseGateway>() is not null, gateway is not null))), deadline.Token);
while (true)
{
var frame = await ReadAsync(process.StandardOutput.BaseStream, deadline.Token);
if (frame.Kind == "result")
return frame.Data.Deserialize<SandboxRunResult>(Json) ?? throw new InvalidDataException("Missing sandbox result.");
if (frame.Kind != "call") throw new InvalidDataException("Unexpected sandbox message.");
deadline.Token.ThrowIfCancellationRequested();
Frame reply;
try
{
var value = await DispatchAsync(frame.Method!, frame.Data, services, catalog, gateway, deadline.Token);
deadline.Token.ThrowIfCancellationRequested();
reply = new Frame("reply", null, Encode(value));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{ reply = new Frame("reply", null, Encode(null), ex is ScriptSandboxException ? ex.Message : "Sandbox gateway request failed."); }
await WriteAsync(process.StandardInput.BaseStream, reply, deadline.Token);
}
}
catch (OperationCanceledException)
{ return new(false, null, null, "", $"Script execution exceeded the {seconds}-second sandbox timeout or was canceled.", SandboxErrorKind.Timeout, clock.ElapsedMilliseconds, null); }
catch (ScriptSandboxException ex)
{ return new(false, null, null, "", ex.Message, SandboxErrorKind.SandboxLimitation, clock.ElapsedMilliseconds, null); }
catch (Exception)
{ return new(false, null, null, "", "Sandbox worker exited or exceeded its resource budget.", SandboxErrorKind.RuntimeError, clock.ElapsedMilliseconds, null); }
finally
{
deadline.Cancel(); // Stop accepting/brokering new operations before termination.
terminateOnDeadline.Dispose();
if (process is not null)
{
try { if (!process.HasExited) process.Kill(entireProcessTree: true); }
catch (InvalidOperationException) when (process.HasExited) { }
await process.WaitForExitAsync(CancellationToken.None);
if (stderr is not null) await stderr;
process.Dispose();
}
if (acquired) Admission.Release();
}
}
private static async Task DrainAsync(Stream stream)
{ var buffer = new byte[4096]; while (await stream.ReadAsync(buffer) != 0) { } }
private static async Task<object?> DispatchAsync(string method, JsonElement args, IServiceProvider services,
ISharedScriptCatalog catalog, ISandboxInstanceGateway? gateway, CancellationToken ct)
{
string Text(int i) => args[i].GetString()!;
IReadOnlyDictionary<string, object?>? Parameters(int i) => args[i].ValueKind == JsonValueKind.Null ? null
: args[i].EnumerateObject().ToDictionary(p => p.Name, p => ScriptAnalysisService.JsonElementToObject(p.Value));
switch (method)
{
case "shared": return await catalog.GetByNameAsync(Text(0), ct);
case "external": return await services.GetRequiredService<IExternalSystemClient>().CallAsync(Text(0), Text(1), Parameters(2), ct);
case "cachedExternal": return await services.GetRequiredService<IExternalSystemClient>().CachedCallAsync(Text(0), Text(1), Parameters(2), Text(3), ct);
case "cachedDb": return await services.GetRequiredService<IDatabaseGateway>().CachedWriteAsync(Text(0), Text(1), Parameters(2), Text(3), ct);
case "connection":
var source = services.GetRequiredService<IDatabaseGateway>() as ISandboxDatabaseConnectionSource
?? throw new ScriptSandboxException("Database worker handoff is unavailable for this provider.");
return new ConnectionInfo("Microsoft.Data.SqlClient.SqlConnection",
await source.GetSandboxConnectionStringAsync(Text(0), ct), true);
case "get": return await (gateway ?? throw new ScriptSandboxException("Instance not bound.")).GetAttributeAsync(Text(0), ct);
case "set": await gateway!.SetAttributeAsync(Text(0), Text(1), ct); return null;
case "script": return await gateway!.CallScriptAsync(Text(0), Parameters(1), ct);
case "wait": return await gateway!.WaitForAttributeAsync(Text(0), args[1].GetString(), TimeSpan.FromTicks(args[2].GetInt64()), args[3].GetBoolean(), ct);
default: throw new ScriptSandboxException("Unknown sandbox operation.");
}
}
private static JsonElement Encode(object? value)
{
using var buffer = new BoundedSandboxStream(FrameLimit - 4096);
JsonSerializer.Serialize(buffer, value, Json);
using var document = JsonDocument.Parse(buffer.GetBuffer().AsMemory(0, (int)buffer.Length));
return document.RootElement.Clone();
}
private static async Task WriteAsync(Stream stream, Frame frame, CancellationToken ct)
{
using var bytes = new BoundedSandboxStream(FrameLimit);
JsonSerializer.Serialize(bytes, frame, Json);
var prefix = new byte[4]; BinaryPrimitives.WriteInt32LittleEndian(prefix, (int)bytes.Length);
await stream.WriteAsync(prefix, ct);
await stream.WriteAsync(bytes.GetBuffer().AsMemory(0, (int)bytes.Length), ct);
await stream.FlushAsync(ct);
}
private static async Task<Frame> ReadAsync(Stream stream, CancellationToken ct)
{
var prefix = new byte[4]; await stream.ReadExactlyAsync(prefix, ct);
var length = BinaryPrimitives.ReadInt32LittleEndian(prefix);
if (length is < 1 or > FrameLimit) throw new InvalidDataException("Sandbox frame budget exceeded.");
var data = new byte[length]; await stream.ReadExactlyAsync(data, ct);
return JsonSerializer.Deserialize<Frame>(data, Json) ?? throw new InvalidDataException("Invalid sandbox frame.");
}
/// <summary>Worker executable entry. Protocol streams are captured before script console routing.</summary>
public static async Task RunWorkerAsync()
{
var input = Console.OpenStandardInput(); var output = Console.OpenStandardOutput();
var first = await ReadAsync(input, CancellationToken.None);
var start = first.Data.Deserialize<Start>(Json) ?? throw new InvalidDataException("Missing worker request.");
var rpc = new Rpc(input, output);
var services = new ServiceCollection();
if (start.External) services.AddSingleton<IExternalSystemClient>(rpc);
if (start.Database) services.AddSingleton<IDatabaseGateway>(rpc);
if (start.Bound) services.AddSingleton<ISandboxInstanceGateway>(rpc);
using var provider = services.BuildServiceProvider();
using var cache = new MemoryCache(new MemoryCacheOptions());
var runner = new ScriptAnalysisService(rpc, cache, provider);
var result = await runner.RunInWorkerAsync(start.Request, CancellationToken.None);
await WriteAsync(output, new Frame("result", null, Encode(result)), CancellationToken.None);
}
private sealed class Rpc(Stream input, Stream output) : ISharedScriptCatalog, IExternalSystemClient, IDatabaseGateway, ISandboxInstanceGateway
{
private readonly SemaphoreSlim _gate = new(1);
private async Task<JsonElement> Call(string method, object?[] args, CancellationToken ct)
{
await _gate.WaitAsync(ct);
try
{
await WriteAsync(output, new Frame("call", method, Encode(args)), ct);
var reply = await ReadAsync(input, ct);
if (reply.Error is not null) throw new ScriptSandboxException(reply.Error);
return reply.Data;
}
finally { _gate.Release(); }
}
public Task<IReadOnlyList<ScriptShape>> GetShapesAsync() => Task.FromResult<IReadOnlyList<ScriptShape>>([]);
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
=> (await Call("shared", [name], cancellationToken)).Deserialize<SharedScriptSource>(Json);
public async Task<ExternalCallResult> CallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, CancellationToken cancellationToken = default)
=> (await Call("external", [systemName, methodName, parameters], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
public async Task<ExternalCallResult> CachedCallAsync(string systemName, string methodName, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedExternal", [systemName, methodName, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
public async Task<ExternalCallResult> CachedWriteAsync(string connectionName, string sql, IReadOnlyDictionary<string, object?>? parameters = null, string? originInstanceName = null, CancellationToken cancellationToken = default, TrackedOperationId? trackedOperationId = null, Guid? executionId = null, string? sourceScript = null, Guid? parentExecutionId = null)
=> (await Call("cachedDb", [connectionName, sql, parameters, originInstanceName], cancellationToken)).Deserialize<ExternalCallResult>(Json)!;
public async Task<DbConnection> GetConnectionAsync(string connectionName, CancellationToken cancellationToken = default)
{
var info = (await Call("connection", [connectionName], cancellationToken)).Deserialize<ConnectionInfo>(Json)!;
DbConnection connection = info.Provider switch {
"Microsoft.Data.SqlClient.SqlConnection" => new Microsoft.Data.SqlClient.SqlConnection(info.ConnectionString),
_ => throw new ScriptSandboxException("Unsupported database provider.") };
if (info.Open) await connection.OpenAsync(cancellationToken);
return connection;
}
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("get", [canonicalName], ct));
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct) => await Call("set", [canonicalName, value], ct);
public async Task<object?> CallScriptAsync(string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
=> ScriptAnalysisService.JsonElementToObject(await Call("script", [canonicalScriptName, parameters], ct));
public async Task<WaitResult> WaitForAttributeAsync(string canonicalName, string? targetValueEncoded, TimeSpan timeout, bool requireGoodQuality, CancellationToken ct)
=> (await Call("wait", [canonicalName, targetValueEncoded, timeout.Ticks, requireGoodQuality], ct)).Deserialize<WaitResult>(Json)!;
}
}
@@ -189,20 +189,9 @@ public class ScriptAnalysisService
private const int SandboxMaxCallSharedDepth = 16;
/// <summary>
/// Process-wide cap on concurrently executing Test Runs (arch-review WP2.4).
/// <para>
/// A sandbox run occupies a thread-pool thread for its whole duration — the
/// <c>SandboxScriptHost</c> attribute accessors are synchronous by contract and block on
/// cross-site I/O (<c>GetAwaiter().GetResult()</c>), so the thread is <em>parked</em>, not
/// merely busy, for up to the 10s sandbox timeout. Unbounded, a handful of Designers
/// each hitting Run on a slow-bound script could park enough pool threads to starve the
/// Blazor circuits sharing this process. The gate bounds that: excess runs wait for a
/// slot instead of taking a thread, and the wait is charged to the caller's own timeout.
/// </para>
/// <para>
/// Static because <c>ScriptAnalysisService</c> is registered scoped — a per-instance gate
/// would be per-request and bound nothing.
/// </para>
/// Secondary guard inside the disposable worker. Parent admission and process
/// termination are enforced by <see cref="SandboxWorkerProtocol"/>; synchronous
/// script helper calls never occupy a central Blazor thread.
/// </summary>
private static readonly SemaphoreSlim SandboxRunGate =
new(SandboxMaxConcurrentRuns, SandboxMaxConcurrentRuns);
@@ -214,7 +203,7 @@ public class ScriptAnalysisService
private const int SandboxMaxConcurrentRuns = 4;
/// <summary>
/// Compiles and runs a script in the central process. The globals surface
/// Compiles and runs a script in a disposable worker process. The globals surface
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
/// scripts against <see cref="SandboxInboundScriptHost"/>.
@@ -242,7 +231,11 @@ public class ScriptAnalysisService
/// <param name="request">The sandbox run request including code, kind, parameters, and optional instance binding.</param>
/// <param name="ct">Cancellation token for the caller; combined with an internal timeout CTS.</param>
/// <returns>A <see cref="SandboxRunResult"/> describing the outcome, return value, console output, and any error.</returns>
public virtual async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
public virtual Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
=> SandboxWorkerProtocol.RunAsync(request, _services, _sharedScripts, ct);
/// <summary>Execution core invoked only by the disposable worker executable.</summary>
public async Task<SandboxRunResult> RunInWorkerAsync(SandboxRunRequest request, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(request.Code))
{
@@ -294,7 +287,7 @@ public class ScriptAnalysisService
// Trust-model gate: the documented forbidden-API set is
// enforced HERE, before execution — not merely surfaced as an editor hint.
// Without this, a Design-role user could run arbitrary file/process/
// reflection/network code in the central host process.
// reflection/network code in the worker process.
var trustViolations = EnforceTrustModel(script.GetCompilation());
if (trustViolations.Count > 0)
{
@@ -313,9 +306,9 @@ public class ScriptAnalysisService
// Optional instance binding: when the Test Run targets a deployed
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
// Attributes/Children/Parent accessors route to it cross-site.
ISandboxInstanceGateway? instanceGateway = null;
var instanceLabel = "test-run";
if (request.Kind != ScriptKind.InboundApi
ISandboxInstanceGateway? instanceGateway = _services.GetService<ISandboxInstanceGateway>();
var instanceLabel = request.BindInstanceUniqueName ?? "test-run";
if (instanceGateway is null && request.Kind != ScriptKind.InboundApi
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
{
var bindName = request.BindInstanceUniqueName.Trim();
@@ -457,7 +450,7 @@ public class ScriptAnalysisService
// restore Console.Out while the other is still writing. SandboxConsoleCapture
// installs routing writers once and scopes capture to this call-tree only.
var (captureOut, captureError) = SandboxConsoleCapture.Install();
var captured = new StringWriter();
var captured = new BoundedSandboxWriter(SandboxMaxConsoleChars);
using var outScope = captureOut.BeginCapture(captured);
using var errorScope = captureError.BeginCapture(captured);
@@ -485,7 +478,7 @@ public class ScriptAnalysisService
Success: true,
ReturnValueJson: returnJson,
ReturnTypeName: returnType,
ConsoleOutput: TruncateConsole(captured.ToString()),
ConsoleOutput: captured.ToString(),
Error: null,
ErrorKind: SandboxErrorKind.None,
DurationMs: stopwatch.ElapsedMilliseconds,
@@ -495,14 +488,14 @@ public class ScriptAnalysisService
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sandboxEx.Message,
captured.ToString(), sandboxEx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
{
stopwatch.Stop();
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
captured.ToString(),
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
}
@@ -513,11 +506,11 @@ public class ScriptAnalysisService
if (inner is ScriptSandboxException sx)
{
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()), sx.Message,
captured.ToString(), sx.Message,
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
}
return new SandboxRunResult(false, null, null,
TruncateConsole(captured.ToString()),
captured.ToString(),
$"{inner.GetType().Name}: {inner.Message}",
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
}
@@ -542,7 +535,7 @@ public class ScriptAnalysisService
return result;
}
private static object? JsonElementToObject(JsonElement element)
internal static object? JsonElementToObject(JsonElement element)
{
return element.ValueKind switch
{
@@ -561,25 +554,17 @@ public class ScriptAnalysisService
private static (string Json, string TypeName) SerializeReturn(object? value)
{
if (value == null) return ("null", "null");
var typeName = value.GetType().Name;
var typeName = value?.GetType().FullName ?? "null";
try
{
var json = JsonSerializer.Serialize(value, SandboxReturnJsonOptions);
if (json.Length > SandboxMaxReturnJsonChars)
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
return (json, typeName);
if (value is string text && text.Length > SandboxMaxReturnJsonChars)
return ("\"<return value truncated>\"", typeName);
using var buffer = new BoundedSandboxStream(SandboxMaxReturnJsonChars);
JsonSerializer.Serialize(buffer, value, SandboxReturnJsonOptions);
return (Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length), typeName);
}
catch (Exception ex)
{
return ($"\"<unserializable: {ex.Message}>\"", typeName);
}
}
private static string TruncateConsole(string text)
{
if (text.Length <= SandboxMaxConsoleChars) return text;
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
catch (InvalidDataException) { return ("\"<return value truncated>\"", typeName); }
catch (Exception) { return ("\"<unserializable>\"", typeName); }
}
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
@@ -55,7 +55,7 @@ public static class ServiceCollectionExtensions
// racing AuditFilterBar's site enumeration on the shared scoped context.
services.AddScoped<IAuditLogQueryService>(sp => new AuditLogQueryService(
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ICentralHealthAggregator>()));
sp.GetRequiredService<ICentralHealthAggregator>(), sp.GetRequiredService<AuthenticationStateProvider>()));
// Server-side streaming CSV export.
// Backs the Audit Log page's Export button via GET /api/centralui/audit/export.
@@ -1,3 +1,6 @@
using Microsoft.AspNetCore.Components.Authorization;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Security;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
@@ -45,6 +48,7 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
// Production path: open a fresh scope per operation. Null in the test-seam ctor.
private readonly IServiceScopeFactory? _scopeFactory;
private readonly AuthenticationStateProvider? _auth;
// Test seam: a directly-injected repository whose lifetime the test owns.
// Null in the production ctor.
@@ -68,9 +72,10 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
/// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
public AuditLogQueryService(
IServiceScopeFactory scopeFactory,
ICentralHealthAggregator healthAggregator)
ICentralHealthAggregator healthAggregator, AuthenticationStateProvider? auth = null)
{
_scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
_auth = auth;
_healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
}
@@ -171,7 +176,12 @@ public sealed class AuditLogQueryService : IAuditLogQueryService
// page's auto-load never shares the circuit-scoped context.
await using var scope = _scopeFactory!.CreateAsyncScope();
var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
return await repository.GetExecutionTreeAsync(executionId, ct);
if (_auth is null) throw new InvalidOperationException("Audit tree caller authentication is not configured.");
var principal = (await _auth.GetAuthoritativeStateAsync()).User;
if (principal.Identity?.IsAuthenticated != true || !principal.FindAll(JwtTokenService.RoleClaimType).Any(c => AuthorizationPolicies.OperationalAuditRoles.Contains(c.Value))) throw new UnauthorizedAccessException();
var sites = principal.FindAll(JwtTokenService.SiteIdClaimType).Select(c => c.Value).ToArray();
var permitted = principal.HasClaim(JwtTokenService.RoleClaimType, Roles.Administrator) || sites.Length == 0 ? null : sites;
return await repository.GetExecutionTreeAsync(executionId, ct, permitted);
}
/// <inheritdoc/>