using System.Security.Claims; using Microsoft.AspNetCore.Components.Authorization; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Security; namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth; /// /// Resolves the set of sites the current user is permitted to operate on, from /// the roles and SiteId claims attached at login. /// /// The design (Component-CentralUI, CLAUDE.md "Security & Auth") makes the /// Deployment role site-scoped: a Deployment user mapped through an LDAP group /// with site-scope rules carries one /// claim per permitted site (the claim value is the integer Site.Id). /// A user with no SiteId claim is system-wide, and so is any user holding /// the role — regardless of the SiteId /// claims they carry. /// /// /// The Administrator bypass is not a convenience, it is the agreed semantic on /// the other side of the same rule. ManagementActor has always exempted /// Administrator from site scoping (EnforceSiteScope and every handler that /// calls it), so a user holding Administrator ALONGSIDE a scoped Deployer grant /// could act fleet-wide through the CLI/HTTP surface while this service hid the /// same sites in the UI — the UI was the side that disagreed with the documented /// rule (deferred-work register row 86). Note that Designer is NOT exempt: the /// enforced rule has only ever bypassed Administrator, and this doc used to claim /// "any Admin/Design user" is system-wide, which was never true of either side. /// /// /// Deployment and Monitoring pages must filter every site/instance list through /// and re-check /// before any cross-site command, so a scoped user cannot view or act on sites /// outside their grant. /// /// public sealed class SiteScopeService { private readonly AuthenticationStateProvider _authStateProvider; /// Initializes a new instance of . /// The Blazor authentication state provider used to read the current user's claims. public SiteScopeService(AuthenticationStateProvider authStateProvider) { _authStateProvider = authStateProvider; } /// /// True when the user is not restricted to a site subset — either because they /// hold or because they carry no /// SiteId claims. System-wide users see and act on every site. /// /// A task that resolves to true if the user has no site-scope restriction. public async Task IsSystemWideAsync() => (await ResolveAsync()).IsSystemWide; /// /// The set of Site.Id values the user may operate on. Empty for a /// system-wide user (callers should consult /// or use the filter/allowed helpers, which already account for that). /// /// A task that resolves to the set of permitted site IDs (empty for system-wide users). public async Task> PermittedSiteIdsAsync() => (await ResolveAsync()).Sites; /// /// Returns the subset of the user is permitted to /// see. A system-wide user gets the full list back unchanged. /// /// The full set of sites to filter. /// A task that resolves to the filtered list of sites the user is permitted to see. public async Task> FilterSitesAsync(IEnumerable sites) { var (isSystemWide, allowed) = await ResolveAsync(); if (isSystemWide) return sites.ToList(); return sites.Where(s => allowed.Contains(s.Id)).ToList(); } /// /// True when the user may operate on the site with the given Site.Id. /// Must be re-checked server-side before any mutating cross-site command. /// /// The Site.Id to check. /// A task that resolves to true when the user may operate on the given site. public async Task IsSiteAllowedAsync(int siteId) { var (isSystemWide, allowed) = await ResolveAsync(); return isSystemWide || allowed.Contains(siteId); } /// /// The site-scope rule as a pure function of an already-resolved principal — /// no I/O, no authentication-state fetch, no session revalidation. /// /// Every instance method here resolves the AUTHORITATIVE state first (a fail-closed /// refresh that re-maps groups to roles against the database), which is correct /// before a mutation but far too expensive for a component that merely decides what /// to render — and ruinous for one that re-decides on a timer. Display-gating /// callers holding a principal (the cascading , or /// a non-forced GetAuthenticationStateAsync) call this instead and pay /// nothing. Anything that ACTS on a site must still go through /// so the check is made against fresh claims. /// /// /// The principal to classify. An unauthenticated principal is scoped to nothing. /// /// Whether the user is unrestricted, and — when they are not — the set of /// Site.Id values they may see. The set is always empty for a system-wide user. /// public static (bool IsSystemWide, IReadOnlySet Sites) Resolve(ClaimsPrincipal user) { ArgumentNullException.ThrowIfNull(user); return ResolveCore(user); } private async Task<(bool IsSystemWide, IReadOnlySet Sites)> ResolveAsync() { var state = await _authStateProvider.GetAuthoritativeStateAsync(); return ResolveCore(state.User); } private static (bool IsSystemWide, IReadOnlySet Sites) ResolveCore(ClaimsPrincipal user) { // Administrator => system-wide, whatever SiteId claims are present. Read // the role CLAIM rather than IsInRole: the principal's role claim type is // only bound to JwtTokenService.RoleClaimType when the identity was built // by SessionClaimBuilder, and the comparison is OrdinalIgnoreCase to match // ManagementActor's — IsInRole compares claim VALUES ordinally, which would // make the two sides disagree on a differently-cased role name. if (user.Identity?.IsAuthenticated != true) return (false, new HashSet()); var isAdministrator = user .FindAll(JwtTokenService.RoleClaimType) .Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase)); var ids = new HashSet(); if (!isAdministrator) { // An Administrator's SiteId claims are not merely overridden, they are // not collected at all: the invariant "system-wide => empty set" then // holds for every caller, so a consumer that reads the id set without // first consulting IsSystemWide cannot silently narrow an Administrator // to a subset of sites. foreach (var claim in user.FindAll(JwtTokenService.SiteIdClaimType)) { if (int.TryParse(claim.Value, out var id)) ids.Add(id); } } // No SiteId claims => system-wide. Absence of scope rules means an // unrestricted deployer (this service is the sole // site-scoping mechanism — there is no separate handler to mirror). var result = (IsSystemWide: isAdministrator || ids.Count == 0, Sites: (IReadOnlySet)ids); return result; } }