diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index 284486e2..b3bf3a26 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -11,6 +11,7 @@ @inject IAlarmSummaryService AlarmSummaryService @inject ISiteRepository SiteRepository @inject ISiteAlarmLiveCache LiveAlarmCache +@inject ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService SiteScope
@@ -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 + //