fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state

This commit is contained in:
Joseph Doherty
2026-05-16 19:33:09 -04:00
parent 5a08b04535
commit 87f14c190a
17 changed files with 693 additions and 40 deletions

View File

@@ -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))

View File

@@ -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;

View File

@@ -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}";

View File

@@ -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
{

View File

@@ -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();

View File

@@ -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(

View File

@@ -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.",