fix(security): close auth & site-scoping gaps across 8 findings

Resolves the auth-theme batch from the 2026-05-28 baseline review (8 findings
across Security/CentralUI/ManagementService/CLI). The most consequential gaps:
NotificationReport + SiteCallsReport now route through SiteScopeService so a
site-scoped Deployment user cannot see or act on other sites' rows (CUI-028);
QueryAuditLogCommand is no longer "any authenticated user" — gated Admin-only
to match /api/audit/query's strictness (MS-018); RoleMapper preserves the
broader grant when a user is in both an unscoped and scoped Deployment LDAP
group, instead of silently narrowing to the scoped set (Sec-016); and the
dead SiteScopeRequirement/Handler are deleted so SiteScopeService is
unambiguously the sole site-scoping mechanism (Sec-017). Pending findings:
172 → 164.
This commit is contained in:
Joseph Doherty
2026-05-28 03:35:29 -04:00
parent f93b7b99bb
commit e536178323
28 changed files with 814 additions and 196 deletions
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Logging;
using ScadaLink.CentralUI.Auth;
using ScadaLink.CentralUI.Components.Shared;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Messages.Audit;
@@ -44,6 +45,7 @@ public partial class SiteCallsReport
private const int PageSize = 50;
[Inject] private NavigationManager Navigation { get; set; } = null!;
[Inject] private SiteScopeService SiteScope { get; set; } = null!;
// The Status filter <select> options — the exact strings the dropdown binds and
// the KPI tiles emit (e.g. ?status=Parked). A query-string status only seeds the
@@ -56,6 +58,11 @@ public partial class SiteCallsReport
private ToastNotification _toast = default!;
private List<Site> _sites = new();
// CentralUI-028: unfiltered site list so a permitted-site lookup resolves
// correctly for a SourceSite whose Site was filtered out of the dropdown.
private List<Site> _allSites = new();
private bool _siteScopeSystemWide;
private HashSet<int> _permittedSiteIds = new();
// List
private List<SiteCallSummary>? _siteCalls;
@@ -94,7 +101,12 @@ public partial class SiteCallsReport
{
try
{
_sites = (await SiteRepository.GetAllSitesAsync()).ToList();
_allSites = (await SiteRepository.GetAllSitesAsync()).ToList();
// CentralUI-028: restrict the source-site dropdown to the user's
// permitted set. System-wide users see the full list back unchanged.
_sites = await SiteScope.FilterSitesAsync(_allSites);
_siteScopeSystemWide = await SiteScope.IsSystemWideAsync();
_permittedSiteIds = new HashSet<int>(await SiteScope.PermittedSiteIdsAsync());
}
catch (Exception ex)
{
@@ -212,7 +224,10 @@ public partial class SiteCallsReport
var response = await CommunicationService.QuerySiteCallsAsync(request);
if (response.Success)
{
_siteCalls = response.SiteCalls.ToList();
// CentralUI-028: drop any row whose source site is outside the
// user's permitted set, as a row-level safety net behind the
// dropdown restriction.
_siteCalls = await FilterPermittedAsync(response.SiteCalls);
_currentCursor = cursor;
// The response echoes the last row's cursor. A short page (fewer
@@ -238,6 +253,15 @@ public partial class SiteCallsReport
private async Task RetrySiteCall(SiteCallSummary c)
{
// CentralUI-028: server-side re-check before relaying — a Retry relay must
// not fire for a site outside the caller's permitted set, even if the row
// somehow appeared in the grid.
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Retry cached call",
$"Relay a retry of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -265,6 +289,12 @@ public partial class SiteCallsReport
private async Task DiscardSiteCall(SiteCallSummary c)
{
if (!await IsRowSiteAllowedAsync(c.SourceSite))
{
_toast.ShowError("You are not permitted to act on cached calls for this site.");
return;
}
var confirmed = await Dialog.ConfirmAsync(
"Discard cached call",
$"Relay a discard of cached call {ShortId(c.TrackedOperationId)} (\"{c.Target}\") " +
@@ -448,4 +478,42 @@ public partial class SiteCallsReport
"Discarded" => "bg-secondary",
_ => "bg-light text-dark"
};
/// <summary>
/// Drops any site-call row whose source site resolves to a Site.Id outside the
/// caller's permitted set. System-wide users get the list back unchanged.
/// Lookup uses <c>_allSites</c> (not <c>_sites</c>) so a row whose Site was
/// filtered OUT of the dropdown is correctly classified as out-of-scope.
/// </summary>
private Task<List<SiteCallSummary>> FilterPermittedAsync(
IEnumerable<SiteCallSummary> calls)
{
if (_siteScopeSystemWide)
return Task.FromResult(calls.ToList());
var filtered = calls
.Where(c =>
{
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == c.SourceSite);
return resolved is null || _permittedSiteIds.Contains(resolved.Id);
})
.ToList();
return Task.FromResult(filtered);
}
/// <summary>
/// Server-side re-check for the Retry/Discard relay. True for a system-wide
/// user, or when the row's source site maps to a Site.Id in the permitted set.
/// </summary>
private Task<bool> IsRowSiteAllowedAsync(string sourceSite)
{
if (_siteScopeSystemWide)
return Task.FromResult(true);
var resolved = _allSites.FirstOrDefault(s => s.SiteIdentifier == sourceSite);
if (resolved is null)
return Task.FromResult(true);
return Task.FromResult(_permittedSiteIds.Contains(resolved.Id));
}
}