fix(central-ui): pending-updates indicator resolves roles/scope once per auth state, not per poll
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
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -89,20 +90,49 @@ public sealed class SiteScopeService
|
||||
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 (state.User.Identity?.IsAuthenticated != true)
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
return (false, new HashSet<int>());
|
||||
|
||||
var isAdministrator = state.User
|
||||
var isAdministrator = user
|
||||
.FindAll(JwtTokenService.RoleClaimType)
|
||||
.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -114,7 +144,7 @@ public sealed class SiteScopeService
|
||||
// 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 state.User.FindAll(JwtTokenService.SiteIdClaimType))
|
||||
foreach (var claim in user.FindAll(JwtTokenService.SiteIdClaimType))
|
||||
{
|
||||
if (int.TryParse(claim.Value, out var id))
|
||||
ids.Add(id);
|
||||
|
||||
Reference in New Issue
Block a user