af9f14a668
SiteScopeService's instance methods resolve the AUTHORITATIVE state — a fail-closed, DB-backed group-to-role re-mapping. The indicator called them on every 30s tick, and it lives in MainLayout, so that was one or two forced session revalidations per open circuit per page, forever: exactly the per-circuit cost IPendingUpdatesCache exists to avoid. Extracts the pure rule as SiteScopeService.Resolve(ClaimsPrincipal) (no I/O; ResolveAsync now delegates to it, no behaviour change for existing callers). The indicator takes the cascaded AuthenticationState — falling back to a NON-forced GetAuthenticationStateAsync, as Templates/FlowDebugger do — and computes roles plus scope once per auth state, re-running on AuthenticationStateChanged so a mid-session revocation hides the block instead of leaving it stale for the life of the circuit. Also routes the first load through PollGate, adds aria-hidden to the icons, and comments the off-renderer field writes and the InvalidOperationException catch. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
161 lines
7.9 KiB
C#
161 lines
7.9 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Resolves the set of sites the current user is permitted to operate on, from
|
|
/// the roles and <c>SiteId</c> claims attached at login.
|
|
/// <para>
|
|
/// 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 <see cref="JwtTokenService.SiteIdClaimType"/>
|
|
/// claim per permitted site (the claim value is the integer <c>Site.Id</c>).
|
|
/// A user with no <c>SiteId</c> claim is system-wide, and so is any user holding
|
|
/// the <see cref="Roles.Administrator"/> role — regardless of the <c>SiteId</c>
|
|
/// claims they carry.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The Administrator bypass is not a convenience, it is the agreed semantic on
|
|
/// the other side of the same rule.</b> <c>ManagementActor</c> has always exempted
|
|
/// Administrator from site scoping (<c>EnforceSiteScope</c> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Deployment and Monitoring pages must filter every site/instance list through
|
|
/// <see cref="FilterSitesAsync"/> and re-check <see cref="IsSiteAllowedAsync"/>
|
|
/// before any cross-site command, so a scoped user cannot view or act on sites
|
|
/// outside their grant.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class SiteScopeService
|
|
{
|
|
private readonly AuthenticationStateProvider _authStateProvider;
|
|
|
|
/// <summary>Initializes a new instance of <see cref="SiteScopeService"/>.</summary>
|
|
/// <param name="authStateProvider">The Blazor authentication state provider used to read the current user's claims.</param>
|
|
public SiteScopeService(AuthenticationStateProvider authStateProvider)
|
|
{
|
|
_authStateProvider = authStateProvider;
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the user is not restricted to a site subset — either because they
|
|
/// hold <see cref="Roles.Administrator"/> or because they carry no
|
|
/// <c>SiteId</c> claims. System-wide users see and act on every site.
|
|
/// </summary>
|
|
/// <returns>A task that resolves to <c>true</c> if the user has no site-scope restriction.</returns>
|
|
public async Task<bool> IsSystemWideAsync()
|
|
=> (await ResolveAsync()).IsSystemWide;
|
|
|
|
/// <summary>
|
|
/// The set of <c>Site.Id</c> values the user may operate on. Empty for a
|
|
/// system-wide user (callers should consult <see cref="IsSystemWideAsync"/>
|
|
/// or use the filter/allowed helpers, which already account for that).
|
|
/// </summary>
|
|
/// <returns>A task that resolves to the set of permitted site IDs (empty for system-wide users).</returns>
|
|
public async Task<IReadOnlySet<int>> PermittedSiteIdsAsync()
|
|
=> (await ResolveAsync()).Sites;
|
|
|
|
/// <summary>
|
|
/// Returns the subset of <paramref name="sites"/> the user is permitted to
|
|
/// see. A system-wide user gets the full list back unchanged.
|
|
/// </summary>
|
|
/// <param name="sites">The full set of sites to filter.</param>
|
|
/// <returns>A task that resolves to the filtered list of sites the user is permitted to see.</returns>
|
|
public async Task<List<Site>> FilterSitesAsync(IEnumerable<Site> sites)
|
|
{
|
|
var (isSystemWide, allowed) = await ResolveAsync();
|
|
if (isSystemWide)
|
|
return sites.ToList();
|
|
return sites.Where(s => allowed.Contains(s.Id)).ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// True when the user may operate on the site with the given <c>Site.Id</c>.
|
|
/// Must be re-checked server-side before any mutating cross-site command.
|
|
/// </summary>
|
|
/// <param name="siteId">The <c>Site.Id</c> to check.</param>
|
|
/// <returns>A task that resolves to <c>true</c> when the user may operate on the given site.</returns>
|
|
public async Task<bool> IsSiteAllowedAsync(int siteId)
|
|
{
|
|
var (isSystemWide, allowed) = await ResolveAsync();
|
|
return isSystemWide || allowed.Contains(siteId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// The site-scope rule as a <b>pure</b> function of an already-resolved principal —
|
|
/// no I/O, no authentication-state fetch, no session revalidation.
|
|
/// <para>
|
|
/// 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 <see cref="AuthenticationState"/>, or
|
|
/// a non-forced <c>GetAuthenticationStateAsync</c>) call this instead and pay
|
|
/// nothing. Anything that ACTS on a site must still go through
|
|
/// <see cref="IsSiteAllowedAsync"/> so the check is made against fresh claims.
|
|
/// </para>
|
|
/// </summary>
|
|
/// <param name="user">The principal to classify. An unauthenticated principal is scoped to nothing.</param>
|
|
/// <returns>
|
|
/// Whether the user is unrestricted, and — when they are not — the set of
|
|
/// <c>Site.Id</c> values they may see. The set is always empty for a system-wide user.
|
|
/// </returns>
|
|
public static (bool IsSystemWide, IReadOnlySet<int> Sites) Resolve(ClaimsPrincipal user)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(user);
|
|
return ResolveCore(user);
|
|
}
|
|
|
|
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
|
|
{
|
|
var state = await _authStateProvider.GetAuthoritativeStateAsync();
|
|
return ResolveCore(state.User);
|
|
}
|
|
|
|
private static (bool IsSystemWide, IReadOnlySet<int> 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<int>());
|
|
|
|
var isAdministrator = user
|
|
.FindAll(JwtTokenService.RoleClaimType)
|
|
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
|
|
|
|
var ids = new HashSet<int>();
|
|
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<int>)ids);
|
|
return result;
|
|
}
|
|
}
|