@@ -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();
+ _notReporting = Array.Empty();
+ _rollup = new AlarmRollup(0, 0, 0, new Dictionary());
+ 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();
- _notReporting = Array.Empty();
- _rollup = new AlarmRollup(0, 0, 0, new Dictionary());
- 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
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs
index 802ef522..8c513ba2 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs
@@ -67,6 +67,7 @@ public class AlarmSummaryRenderTests : BunitContext
Services.AddSingleton(_siteRepo);
Services.AddSingleton(_liveCache);
+ Services.AddScoped();
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(new TestAuthStateProvider(scopedUser));
+
+ var cut = Render();
+
+ 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
+ //