fix(centralui): enforce site scoping on AlarmSummary — filter site list + deny out-of-scope alarm fetches, mirroring DebugView (arch-review 2026-08-19 P6-2 High)
AlarmSummary.razor was gated RequireDeployment but never consulted
SiteScopeService: the site picker listed every site unfiltered and
GetSiteAlarmsAsync ran for whatever siteId the onchange event carried,
so a Deployer scoped to one site could watch any site's live alarms
(including CurrentValue/LimitValue process data). The page was missed
by the ad0100f2 scoping survey.
Fix mirrors EventLogs.razor/DebugView.razor: OnInitializedAsync filters
_sites through SiteScope.FilterSitesAsync, and both the site-change
handler and RefreshAsync (the shared fetch path used by the manual
refresh button and the 15s poll timer) independently re-check the
selected site against the filtered list before ever calling
AlarmSummaryService, denying/resetting an out-of-scope id rather than
merely hiding it from the dropdown.
Tests: bUnit render tests added to AlarmSummaryRenderTests.cs proving
(1) a scoped Deployer's picker omits the out-of-scope site and (2) a
forged out-of-scope onchange value is denied — no row renders and
GetSiteAlarmsAsync is never called for that site. AlarmSummaryVirtualizeTests.cs
updated to register the now-required SiteScopeService.
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
@inject IAlarmSummaryService AlarmSummaryService
|
||||
@inject ISiteRepository SiteRepository
|
||||
@inject ISiteAlarmLiveCache LiveAlarmCache
|
||||
@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
|
||||
|
||||
<div class="container-fluid mt-3" data-test="alarm-summary">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
@@ -275,7 +276,9 @@
|
||||
{
|
||||
try
|
||||
{
|
||||
_sites = await SiteRepository.GetAllSitesAsync();
|
||||
// Site scoping (CentralUI-002, arch-review P6-2): a scoped Deployment
|
||||
// user may only watch live alarms for the sites they are permitted on.
|
||||
_sites = await SiteScope.FilterSitesAsync(await SiteRepository.GetAllSitesAsync());
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -283,17 +286,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
// _sites is already filtered, so membership IS the scope check — mirrors
|
||||
// EventLogs.razor's SelectedSiteIsPermitted.
|
||||
private bool IsSiteSelectionPermitted(int siteId) => _sites.Any(s => s.Id == siteId);
|
||||
|
||||
private void ClearSiteSelection()
|
||||
{
|
||||
_selectedSiteId = null;
|
||||
_rows = Array.Empty<AlarmSummaryRow>();
|
||||
_notReporting = Array.Empty<string>();
|
||||
_rollup = new AlarmRollup(0, 0, 0, new Dictionary<AlarmKind, int>());
|
||||
StopTimer();
|
||||
DisposeLiveSubscription();
|
||||
}
|
||||
|
||||
private async Task OnSiteChangedAsync(ChangeEventArgs e)
|
||||
{
|
||||
var raw = e.Value?.ToString();
|
||||
if (string.IsNullOrEmpty(raw) || !int.TryParse(raw, out var siteId))
|
||||
{
|
||||
_selectedSiteId = null;
|
||||
_rows = Array.Empty<AlarmSummaryRow>();
|
||||
_notReporting = Array.Empty<string>();
|
||||
_rollup = new AlarmRollup(0, 0, 0, new Dictionary<AlarmKind, int>());
|
||||
StopTimer();
|
||||
DisposeLiveSubscription();
|
||||
ClearSiteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
// Site scoping (CentralUI-002, arch-review P6-2): the dropdown is already
|
||||
// filtered, but the selection must not be trusted on its own — re-check
|
||||
// server-side so a forged onchange value naming an out-of-scope site is
|
||||
// denied rather than merely hidden from the picker.
|
||||
if (!IsSiteSelectionPermitted(siteId))
|
||||
{
|
||||
ClearSiteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -319,6 +341,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Site scoping (CentralUI-002, arch-review P6-2): re-check before every
|
||||
// fetch, not just at selection time — defense in depth against
|
||||
// _selectedSiteId ever being set by a path that skips OnSiteChangedAsync's
|
||||
// check, so an out-of-scope site can never reach AlarmSummaryService.
|
||||
if (!IsSiteSelectionPermitted(siteId))
|
||||
{
|
||||
ClearSiteSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -67,6 +67,7 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
Services.AddSingleton(_siteRepo);
|
||||
|
||||
Services.AddSingleton<ISiteAlarmLiveCache>(_liveCache);
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
@@ -278,6 +279,53 @@ public class AlarmSummaryRenderTests : BunitContext
|
||||
});
|
||||
}
|
||||
|
||||
// ── arch-review P6-2: site scoping (a scoped Deployer must not watch another
|
||||
// site's live alarms — the page was missed by the ad0100f2 scoping survey) ──
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_ScopedDeploymentUser_SeesOnlyPermittedSiteInPicker()
|
||||
{
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Site 1 only
|
||||
}, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
var cut = Render<AlarmSummaryPage>();
|
||||
|
||||
var options = cut.FindAll("[data-test='alarm-summary-site'] option");
|
||||
// "Select site…" placeholder + Site 1 only — Site 2 must not appear.
|
||||
Assert.Equal(2, options.Count);
|
||||
Assert.DoesNotContain("Site 2", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SiteScoping_ForgedOutOfScopeSiteId_IsDeniedNotFetched()
|
||||
{
|
||||
// The picker never renders Site 2 for this user, but the change handler
|
||||
// must independently reject an out-of-scope id arriving via any onchange
|
||||
// value (e.g. a forged one, since Blazor Server processes the SignalR
|
||||
// event server-side) rather than trusting it because it came through the
|
||||
// <select> element.
|
||||
var scopedUser = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(JwtTokenService.UsernameClaimType, "scoped"),
|
||||
new Claim(JwtTokenService.RoleClaimType, "Deployer"),
|
||||
new Claim(JwtTokenService.SiteIdClaimType, "1"), // Site 1 only
|
||||
}, "TestAuth"));
|
||||
Services.AddSingleton<AuthenticationStateProvider>(new TestAuthStateProvider(scopedUser));
|
||||
|
||||
var cut = Render<AlarmSummaryPage>();
|
||||
cut.Find("[data-test='alarm-summary-site']").Change("2"); // out-of-scope, forged
|
||||
|
||||
// Denied server-side: no alarm table renders, selection resets to empty.
|
||||
Assert.Empty(cut.FindAll("tr[data-test='alarm-summary-row']"));
|
||||
Assert.NotNull(cut.Find("[data-test='alarm-summary-empty']"));
|
||||
_summary.DidNotReceive().GetSiteAlarmsAsync(2, Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
// ── arch-review R2 N7: disposal guard on the live callback ─────────────────
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -49,6 +49,7 @@ public class AlarmSummaryVirtualizeTests : BunitContext
|
||||
}));
|
||||
Services.AddSingleton(_siteRepo);
|
||||
Services.AddSingleton<ISiteAlarmLiveCache>(new InertLiveCache());
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user