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;
}
}