fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state
This commit is contained in:
@@ -7,23 +7,37 @@ namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
||||
/// The cookie middleware has already validated and decrypted the cookie by the time
|
||||
/// the Blazor circuit is established, so we just read HttpContext.User.
|
||||
/// <para>
|
||||
/// The cookie middleware validates and decrypts the cookie during the initial
|
||||
/// HTTP request that establishes the Blazor circuit. This provider is registered
|
||||
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
|
||||
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
|
||||
/// the authenticated principal <b>once</b> in the constructor and serve that
|
||||
/// snapshot for the lifetime of the circuit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
|
||||
/// <see cref="GetAuthenticationStateAsync"/> call (CentralUI-004): for the
|
||||
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
|
||||
/// (or, worse, a stale/foreign context), so a later re-evaluation —
|
||||
/// e.g. <c><AuthorizeView></c> re-rendering — would otherwise see an
|
||||
/// unauthenticated principal and render the wrong UI.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly Task<AuthenticationState> _circuitAuthState;
|
||||
|
||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
// Snapshot the principal at circuit-construction time. HttpContext is
|
||||
// valid here (initial HTTP request) and will not be afterwards.
|
||||
var user = httpContextAccessor.HttpContext?.User
|
||||
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User
|
||||
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
return Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
=> _circuitAuthState;
|
||||
}
|
||||
|
||||
93
src/ScadaLink.CentralUI/Auth/SiteScopeService.cs
Normal file
93
src/ScadaLink.CentralUI/Auth/SiteScopeService.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the set of sites the current user is permitted to operate on, from
|
||||
/// the <c>SiteId</c> claims attached at login (CentralUI-002).
|
||||
/// <para>
|
||||
/// The design (Component-CentralUI, CLAUDE.md "Security & Auth") makes the
|
||||
/// Deployment role site-scoped: a Deployment user mapped through an LDAP group
|
||||
/// with site-scope rules carries one <see cref="JwtTokenService.SiteIdClaimType"/>
|
||||
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
|
||||
/// A Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
|
||||
/// system-wide.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deployment and Monitoring pages must filter every site/instance list through
|
||||
/// <see cref="FilterSitesAsync"/> and re-check <see cref="IsSiteAllowedAsync"/>
|
||||
/// before any cross-site command, so a scoped user cannot view or act on sites
|
||||
/// outside their grant.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class SiteScopeService
|
||||
{
|
||||
private readonly AuthenticationStateProvider _authStateProvider;
|
||||
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
|
||||
|
||||
public SiteScopeService(AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
|
||||
/// claims). System-wide users see and act on every site.
|
||||
/// </summary>
|
||||
public async Task<bool> IsSystemWideAsync()
|
||||
=> (await ResolveAsync()).IsSystemWide;
|
||||
|
||||
/// <summary>
|
||||
/// The set of <c>Site.Id</c> values the user may operate on. Empty for a
|
||||
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
|
||||
/// or use the filter/allowed helpers, which already account for that).
|
||||
/// </summary>
|
||||
public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync()
|
||||
=> (await ResolveAsync()).Sites;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the subset of <paramref name="sites"/> the user is permitted to
|
||||
/// see. A system-wide user gets the full list back unchanged.
|
||||
/// </summary>
|
||||
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
|
||||
{
|
||||
var (isSystemWide, allowed) = await ResolveAsync();
|
||||
if (isSystemWide)
|
||||
return sites.ToList();
|
||||
return sites.Where(s => allowed.Contains(s.Id)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user may operate on the site with the given <c>Site.Id</c>.
|
||||
/// Must be re-checked server-side before any mutating cross-site command.
|
||||
/// </summary>
|
||||
public async Task<bool> IsSiteAllowedAsync(int siteId)
|
||||
{
|
||||
var (isSystemWide, allowed) = await ResolveAsync();
|
||||
return isSystemWide || allowed.Contains(siteId);
|
||||
}
|
||||
|
||||
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
|
||||
{
|
||||
if (_cached is { } cached)
|
||||
return cached;
|
||||
|
||||
var state = await _authStateProvider.GetAuthenticationStateAsync();
|
||||
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
|
||||
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var claim in siteClaims)
|
||||
{
|
||||
if (int.TryParse(claim.Value, out var id))
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler:
|
||||
// absence of scope rules means an unrestricted deployer.
|
||||
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
|
||||
_cached = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject DebugStreamService DebugStreamService
|
||||
@inject IJSRuntime JS
|
||||
@implements IDisposable
|
||||
@@ -296,7 +297,9 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user may only
|
||||
// debug sites they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -358,6 +361,14 @@
|
||||
_siteInstances.Clear();
|
||||
_selectedInstanceId = 0;
|
||||
if (_selectedSiteId == 0) return;
|
||||
// Site scoping (CentralUI-002): re-check the claim server-side — a query
|
||||
// string or stale localStorage value could name a site outside the grant.
|
||||
if (!await SiteScope.IsSiteAllowedAsync(_selectedSiteId))
|
||||
{
|
||||
_selectedSiteId = 0;
|
||||
_toast.ShowError("You are not permitted to debug instances on that site.");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
_siteInstances = (await TemplateEngineRepository.GetInstancesBySiteIdAsync(_selectedSiteId))
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject IDeploymentManagerRepository DeploymentManagerRepository
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@implements IDisposable
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@@ -245,13 +246,23 @@
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
|
||||
.OrderByDescending(r => r.DeployedAt)
|
||||
.ToList();
|
||||
|
||||
// Build instance name lookup
|
||||
// Build instance lookups first — site scoping (CentralUI-002) filters
|
||||
// deployment records by the site of their instance.
|
||||
var instances = await TemplateEngineRepository.GetAllInstancesAsync();
|
||||
_instanceNames = instances.ToDictionary(i => i.Id, i => i.UniqueName);
|
||||
var instanceSiteIds = instances.ToDictionary(i => i.Id, i => i.SiteId);
|
||||
|
||||
var systemWide = await SiteScope.IsSystemWideAsync();
|
||||
var permittedSiteIds = systemWide
|
||||
? null
|
||||
: await SiteScope.PermittedSiteIdsAsync();
|
||||
|
||||
_records = (await DeploymentManagerRepository.GetAllDeploymentRecordsAsync())
|
||||
.Where(r => permittedSiteIds == null
|
||||
|| (instanceSiteIds.TryGetValue(r.InstanceId, out var sid)
|
||||
&& permittedSiteIds.Contains(sid)))
|
||||
.OrderByDescending(r => r.DeployedAt)
|
||||
.ToList();
|
||||
|
||||
_totalPages = Math.Max(1, (int)Math.Ceiling(_records.Count / (double)PageSize));
|
||||
if (_currentPage > _totalPages) _currentPage = 1;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject InstanceService InstanceService
|
||||
@inject IFlatteningPipeline FlatteningPipeline
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@@ -377,6 +378,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user must not be
|
||||
// able to configure or deploy an instance on a site outside their
|
||||
// grant by navigating straight to its URL.
|
||||
if (!await SiteScope.IsSiteAllowedAsync(_instance.SiteId))
|
||||
{
|
||||
_instance = null;
|
||||
_errorMessage = "You are not permitted to manage instances on this site.";
|
||||
_loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Identity
|
||||
var template = await TemplateEngineRepository.GetTemplateByIdAsync(_instance.TemplateId);
|
||||
_templateName = template?.Name ?? $"#{_instance.TemplateId}";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
@attribute [Authorize(Policy = AuthorizationPolicies.RequireDeployment)]
|
||||
@inject ITemplateEngineRepository TemplateEngineRepository
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject InstanceService InstanceService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager NavigationManager
|
||||
@@ -93,7 +94,9 @@
|
||||
try
|
||||
{
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user may only
|
||||
// create instances on sites they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
|
||||
_allAreas.Clear();
|
||||
foreach (var site in _sites)
|
||||
@@ -124,6 +127,13 @@
|
||||
if (string.IsNullOrWhiteSpace(_createName)) { _formError = "Instance name is required."; return; }
|
||||
if (_createTemplateId == 0) { _formError = "Select a template."; return; }
|
||||
if (_createSiteId == 0) { _formError = "Select a site."; return; }
|
||||
// Site scoping (CentralUI-002): re-check server-side before the mutating
|
||||
// command, independent of what the site dropdown was populated with.
|
||||
if (!await SiteScope.IsSiteAllowedAsync(_createSiteId))
|
||||
{
|
||||
_formError = "You are not permitted to create instances on the selected site.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
@inject AreaService AreaService
|
||||
@inject InstanceService InstanceService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject IDialogService Dialog
|
||||
@@ -225,8 +226,13 @@
|
||||
_errorMessage = null;
|
||||
try
|
||||
{
|
||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync()).ToList();
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user only sees the
|
||||
// sites — and therefore the areas/instances — they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
var permittedSiteIds = _sites.Select(s => s.Id).ToHashSet();
|
||||
_allInstances = (await TemplateEngineRepository.GetAllInstancesAsync())
|
||||
.Where(i => permittedSiteIds.Contains(i.SiteId))
|
||||
.ToList();
|
||||
_templates = (await TemplateEngineRepository.GetAllTemplatesAsync()).ToList();
|
||||
|
||||
_allAreas.Clear();
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@using ScadaLink.Commons.Messages.RemoteQuery
|
||||
@using ScadaLink.Communication
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject CommunicationService CommunicationService
|
||||
|
||||
<div class="container-fluid mt-3">
|
||||
@@ -212,9 +213,16 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user may only query
|
||||
// event logs for the sites they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
}
|
||||
|
||||
// _sites is already filtered, so membership IS the scope check.
|
||||
private bool SelectedSiteIsPermitted =>
|
||||
!string.IsNullOrEmpty(_selectedSiteId)
|
||||
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
|
||||
|
||||
private async Task Search()
|
||||
{
|
||||
_entries = new();
|
||||
@@ -237,6 +245,14 @@
|
||||
{
|
||||
_searching = true;
|
||||
_errorMessage = null;
|
||||
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
|
||||
// filtered, but the selection must not be trusted on its own.
|
||||
if (!SelectedSiteIsPermitted)
|
||||
{
|
||||
_errorMessage = "You are not permitted to view event logs for that site.";
|
||||
_searching = false;
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var request = new EventLogQueryRequest(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using ScadaLink.Commons.Types.Enums
|
||||
@using ScadaLink.Communication
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ScadaLink.CentralUI.Auth.SiteScopeService SiteScope
|
||||
@inject CommunicationService CommunicationService
|
||||
@inject IJSRuntime JS
|
||||
@inject IDialogService Dialog
|
||||
@@ -360,9 +361,17 @@
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
|
||||
// Site scoping (CentralUI-002): a scoped Deployment user may only inspect
|
||||
// and act on parked messages for the sites they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
}
|
||||
|
||||
// True only when the currently selected SiteIdentifier is one this user is
|
||||
// permitted on. _sites is already filtered, so membership IS the scope check.
|
||||
private bool SelectedSiteIsPermitted =>
|
||||
!string.IsNullOrEmpty(_selectedSiteId)
|
||||
&& _sites.Any(s => s.SiteIdentifier == _selectedSiteId);
|
||||
|
||||
private async Task OnSiteChanged(ChangeEventArgs e)
|
||||
{
|
||||
_selectedSiteId = e.Value?.ToString() ?? string.Empty;
|
||||
@@ -393,6 +402,15 @@
|
||||
{
|
||||
_searching = true;
|
||||
_errorMessage = null;
|
||||
// Site scoping (CentralUI-002): re-check before querying — the dropdown is
|
||||
// filtered, but the selection must not be trusted on its own.
|
||||
if (!SelectedSiteIsPermitted)
|
||||
{
|
||||
_errorMessage = "You are not permitted to view parked messages for that site.";
|
||||
_messages = null;
|
||||
_searching = false;
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
var request = new ParkedMessageQueryRequest(
|
||||
@@ -557,6 +575,7 @@
|
||||
{
|
||||
var ids = _selectedIds.ToList();
|
||||
if (ids.Count == 0) return;
|
||||
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Retry parked messages",
|
||||
@@ -587,6 +606,7 @@
|
||||
{
|
||||
var ids = _selectedIds.ToList();
|
||||
if (ids.Count == 0) return;
|
||||
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked messages",
|
||||
@@ -618,6 +638,7 @@
|
||||
|
||||
private async Task RetrySingle(ParkedMessageEntry msg)
|
||||
{
|
||||
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return; }
|
||||
_actionInProgress = true;
|
||||
_activeAction = "Retry";
|
||||
try
|
||||
@@ -638,6 +659,7 @@
|
||||
|
||||
private async Task<bool> DiscardSingle(ParkedMessageEntry msg)
|
||||
{
|
||||
if (!SelectedSiteIsPermitted) { _toast.ShowError("Not permitted for this site."); return false; }
|
||||
var confirmed = await Dialog.ConfirmAsync(
|
||||
"Discard parked message",
|
||||
$"Permanently discard message {ShortId(msg.MessageId)}? This cannot be undone.",
|
||||
|
||||
106
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxConsoleCapture.cs
Normal file
106
src/ScadaLink.CentralUI/ScriptAnalysis/SandboxConsoleCapture.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Per-call console capture for the Test Run sandbox.
|
||||
/// <para>
|
||||
/// Sandbox scripts use <c>System.Console.WriteLine</c> for ad-hoc output. The
|
||||
/// sandbox needs to capture that output per execution. <c>Console.Out</c> is,
|
||||
/// however, <b>process-global</b>: redirecting it with <c>Console.SetOut</c> for
|
||||
/// the duration of one run corrupts any other run executing concurrently —
|
||||
/// outputs interleave, and whichever run finishes first restores
|
||||
/// <c>Console.Out</c> while the others are still writing (CentralUI-003).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This writer is installed into <c>Console.Out</c>/<c>Console.Error</c>
|
||||
/// <b>exactly once</b> (see <see cref="Install"/>) and never removed. Each
|
||||
/// concurrent run pushes its own buffer onto an <see cref="AsyncLocal{T}"/>
|
||||
/// scope via <see cref="BeginCapture"/>; writes on that run's logical call-tree
|
||||
/// land in that run's buffer only. Writes made on threads with no active
|
||||
/// capture scope (i.e. genuine host-process console output) fall through to the
|
||||
/// original writer. No process-global mutation happens per run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
internal sealed class SandboxConsoleCapture : TextWriter
|
||||
{
|
||||
private static readonly object InstallLock = new();
|
||||
private static SandboxConsoleCapture? _outInstance;
|
||||
private static SandboxConsoleCapture? _errorInstance;
|
||||
|
||||
private readonly TextWriter _fallback;
|
||||
private readonly AsyncLocal<StringWriter?> _current = new();
|
||||
|
||||
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
|
||||
|
||||
public override Encoding Encoding => _fallback.Encoding;
|
||||
|
||||
/// <summary>
|
||||
/// Installs the routing writers into <see cref="Console.Out"/> and
|
||||
/// <see cref="Console.Error"/> once for the process. Idempotent and
|
||||
/// thread-safe. Subsequent calls return the already-installed instances.
|
||||
/// </summary>
|
||||
public static (SandboxConsoleCapture Out, SandboxConsoleCapture Error) Install()
|
||||
{
|
||||
if (_outInstance != null && _errorInstance != null)
|
||||
return (_outInstance, _errorInstance);
|
||||
|
||||
lock (InstallLock)
|
||||
{
|
||||
if (_outInstance == null)
|
||||
{
|
||||
_outInstance = new SandboxConsoleCapture(Console.Out);
|
||||
Console.SetOut(_outInstance);
|
||||
}
|
||||
|
||||
if (_errorInstance == null)
|
||||
{
|
||||
_errorInstance = new SandboxConsoleCapture(Console.Error);
|
||||
Console.SetError(_errorInstance);
|
||||
}
|
||||
}
|
||||
|
||||
return (_outInstance, _errorInstance);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begins a capture scope on the current logical (async) call-tree. All
|
||||
/// console writes from this point until the returned scope is disposed are
|
||||
/// routed into <paramref name="buffer"/> instead of the original writer.
|
||||
/// The scope is restored on dispose, so nesting and concurrent scopes on
|
||||
/// other call-trees are unaffected.
|
||||
/// </summary>
|
||||
public CaptureScope BeginCapture(StringWriter buffer)
|
||||
{
|
||||
var previous = _current.Value;
|
||||
_current.Value = buffer;
|
||||
return new CaptureScope(this, previous);
|
||||
}
|
||||
|
||||
public override void Write(char value) => Target.Write(value);
|
||||
|
||||
public override void Write(string? value) => Target.Write(value);
|
||||
|
||||
public override void Write(char[] buffer, int index, int count) =>
|
||||
Target.Write(buffer, index, count);
|
||||
|
||||
public override void WriteLine() => Target.WriteLine();
|
||||
|
||||
public override void WriteLine(string? value) => Target.WriteLine(value);
|
||||
|
||||
private TextWriter Target => _current.Value ?? _fallback;
|
||||
|
||||
internal readonly struct CaptureScope : IDisposable
|
||||
{
|
||||
private readonly SandboxConsoleCapture _owner;
|
||||
private readonly StringWriter? _previous;
|
||||
|
||||
internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
|
||||
{
|
||||
_owner = owner;
|
||||
_previous = previous;
|
||||
}
|
||||
|
||||
public void Dispose() => _owner._current.Value = _previous;
|
||||
}
|
||||
}
|
||||
@@ -165,8 +165,10 @@ public class ScriptAnalysisService
|
||||
/// because a shared script has no template siblings in this context.
|
||||
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
|
||||
/// because cross-site routing needs a deployed site.
|
||||
/// Console.Out / Console.Error are redirected per-call so writes from
|
||||
/// the script land in the result.
|
||||
/// Console.Out / Console.Error are captured per-call via an AsyncLocal
|
||||
/// scope (see <see cref="SandboxConsoleCapture"/>) so writes from the script
|
||||
/// land in the result without mutating process-global Console state — two
|
||||
/// concurrent Test Runs do not interfere with each other.
|
||||
/// </summary>
|
||||
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -377,16 +379,19 @@ public class ScriptAnalysisService
|
||||
Instance = instanceContext,
|
||||
};
|
||||
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
// Console capture is routed per-call via an AsyncLocal scope (CentralUI-003).
|
||||
// Console.Out is process-global, so it must NOT be redirected per run — two
|
||||
// concurrent Test Runs would interleave output and the first to finish would
|
||||
// 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();
|
||||
using var outScope = captureOut.BeginCapture(captured);
|
||||
using var errorScope = captureError.BeginCapture(captured);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
Console.SetOut(captured);
|
||||
Console.SetError(captured);
|
||||
|
||||
// Run on a thread-pool thread with no SynchronizationContext: a
|
||||
// bound script's Instance.SetAttribute / Attributes[...] block
|
||||
// synchronously on cross-site I/O (the API surface is sync by
|
||||
@@ -437,11 +442,9 @@ public class ScriptAnalysisService
|
||||
$"{inner.GetType().Name}: {inner.Message}",
|
||||
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
}
|
||||
// outScope / errorScope are disposed by their `using` declarations when the
|
||||
// method returns, restoring the previous capture scope on this call-tree
|
||||
// without touching process-global Console state.
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ConvertJsonParameters(
|
||||
|
||||
@@ -18,6 +18,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||
services.AddCascadingAuthenticationState();
|
||||
|
||||
// Resolves the current user's permitted site set from their SiteId claims
|
||||
// so Deployment/Monitoring pages can enforce site scoping (CentralUI-002).
|
||||
services.AddScoped<SiteScopeService>();
|
||||
|
||||
// Centralised dialog service: pages inject IDialogService and a single
|
||||
// <DialogHost /> in MainLayout renders the active dialog. See
|
||||
// Components/Shared/IDialogService.cs.
|
||||
|
||||
Reference in New Issue
Block a user