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);
|
||||
|
||||
+150
-34
@@ -1,9 +1,9 @@
|
||||
@implements IDisposable
|
||||
@using System.Security.Claims
|
||||
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
|
||||
@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates
|
||||
@using ZB.MOM.WW.ScadaBridge.Security
|
||||
@inject IPendingUpdatesCache PendingUpdates
|
||||
@inject SiteScopeService SiteScope
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject ILogger<PendingUpdatesIndicator> Logger
|
||||
|
||||
@@ -12,7 +12,15 @@
|
||||
and quiet by default: one muted line when the fleet is current, two lines with
|
||||
a warning pill when something is pending. Reads the process-wide single-flight
|
||||
IPendingUpdatesCache, so N circuits polling here cost at most one fleet
|
||||
flatten per cache TTL. *@
|
||||
flatten per cache TTL.
|
||||
|
||||
Roles and site scope are resolved from the CASCADED authentication state, once
|
||||
per auth state — never per poll. This component lives in MainLayout, so it is
|
||||
on every page of every circuit: an authoritative (fail-closed, DB-backed)
|
||||
revalidation on each 30s tick would be exactly the per-circuit cost the shared
|
||||
cache exists to avoid. Visibility still follows the session — it recomputes on
|
||||
AuthenticationStateChanged, so a mid-session role revocation takes effect
|
||||
immediately instead of persisting for the life of the circuit. *@
|
||||
@if (_visible)
|
||||
{
|
||||
<div class="rail-updates" title="@AsOfCaption">
|
||||
@@ -20,20 +28,20 @@
|
||||
{
|
||||
@* A read failure is shown as a failure, never as a clean bill of health;
|
||||
the exception message is the tooltip so it stays out of the rail. *@
|
||||
<span class="rail-updates-muted" title="@_error"><i class="bi bi-question-circle"></i> Updates unavailable</span>
|
||||
<span class="rail-updates-muted" title="@_error"><i class="bi bi-question-circle" aria-hidden="true"></i> Updates unavailable</span>
|
||||
}
|
||||
else if (_snapshot is null)
|
||||
{
|
||||
<span class="rail-updates-muted"><i class="bi bi-arrow-repeat"></i> Checking for updates…</span>
|
||||
<span class="rail-updates-muted"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Checking for updates…</span>
|
||||
}
|
||||
else if (!_snapshot.HasPendingUpdates)
|
||||
{
|
||||
<span class="rail-updates-muted"><i class="bi bi-check2-circle"></i> Up to date</span>
|
||||
<span class="rail-updates-muted"><i class="bi bi-check2-circle" aria-hidden="true"></i> Up to date</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="rail-updates-head">
|
||||
<i class="bi bi-exclamation-triangle"></i> Pending updates
|
||||
<i class="bi bi-exclamation-triangle" aria-hidden="true"></i> Pending updates
|
||||
<span class="badge bg-warning text-dark">@(StaleInstances + StaleArtifactSites)</span>
|
||||
</span>
|
||||
<span class="rail-updates-detail">
|
||||
@@ -72,18 +80,32 @@
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly PollGate _pollGate = new();
|
||||
private readonly object _timerLock = new();
|
||||
|
||||
private Timer? _timer;
|
||||
private volatile bool _disposed;
|
||||
|
||||
private bool _visible;
|
||||
private volatile bool _visible;
|
||||
private bool _isDeployer;
|
||||
private bool _isAdministrator;
|
||||
|
||||
// null => system-wide (no narrowing). A single reference field rather than a
|
||||
// (bool, set) pair so the poll thread reads one consistent value even if an
|
||||
// auth-state change is applying concurrently.
|
||||
private volatile IReadOnlySet<int>? _scopedSiteIds;
|
||||
|
||||
private PendingUpdatesSnapshot? _snapshot;
|
||||
private string? _error;
|
||||
private string? _lastLoggedError;
|
||||
|
||||
/// <summary>
|
||||
/// The ambient authentication state supplied by the app's
|
||||
/// <c>CascadingAuthenticationState</c>. Preferred over asking the provider so the
|
||||
/// principal already resolved for the page is reused rather than re-fetched.
|
||||
/// </summary>
|
||||
[CascadingParameter]
|
||||
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
|
||||
private int StaleInstances => _snapshot?.StaleInstanceCount ?? 0;
|
||||
|
||||
private int StaleArtifactSites => _snapshot?.StaleArtifactSiteCount ?? 0;
|
||||
@@ -100,46 +122,127 @@
|
||||
private static string Plural(int count, string noun)
|
||||
=> count == 1 ? $"{count} {noun}" : $"{count} {noun}s";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
// A mid-session role change (revocation, or a re-login as someone else on
|
||||
// the same circuit) must move this block, not leave it showing what the
|
||||
// previous principal was allowed to see.
|
||||
AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
|
||||
}
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
// Non-forced on purpose: this is display gating, not a mutation, so the
|
||||
// cached principal is the right input (same pattern as Templates.razor /
|
||||
// FlowDebugger). Anything that ACTS on a site re-checks authoritatively.
|
||||
var state = AuthenticationStateTask is not null
|
||||
? await AuthenticationStateTask
|
||||
: await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
|
||||
ApplyPrincipal(state.User);
|
||||
}
|
||||
|
||||
// Roles and site scope are both pure functions of the principal's claims, so
|
||||
// one resolution answers "do I render?", "do I link?" and "which sites count?".
|
||||
private void ApplyPrincipal(ClaimsPrincipal user)
|
||||
{
|
||||
// Role gating is a plain claim check rather than an AuthorizeView: the whole
|
||||
// component is hidden, and the two breakdown links are gated independently,
|
||||
// so there is no policy to evaluate — just two booleans resolved once.
|
||||
var state = await AuthStateProvider.GetAuthoritativeStateAsync();
|
||||
var roles = state.User.FindAll(JwtTokenService.RoleClaimType).ToList();
|
||||
// so there is no policy to evaluate — just two booleans. The comparison is
|
||||
// OrdinalIgnoreCase to match SiteScopeService/ManagementActor.
|
||||
var roles = user.FindAll(JwtTokenService.RoleClaimType).ToList();
|
||||
|
||||
_isDeployer = roles.Any(c => string.Equals(c.Value, Roles.Deployer, StringComparison.OrdinalIgnoreCase));
|
||||
_isAdministrator = roles.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
|
||||
_visible = _isDeployer || _isAdministrator;
|
||||
|
||||
var (isSystemWide, sites) = SiteScopeService.Resolve(user);
|
||||
_scopedSiteIds = isSystemWide ? null : sites;
|
||||
}
|
||||
|
||||
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
|
||||
=> _ = ApplyAuthenticationStateAsync(task);
|
||||
|
||||
private async Task ApplyAuthenticationStateAsync(Task<AuthenticationState> task)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var state = await task;
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyPrincipal(state.User);
|
||||
|
||||
// A role GRANTED mid-session has to start the poll that first render
|
||||
// skipped; a role revoked just stops rendering (LoadAsync no-ops).
|
||||
if (_visible)
|
||||
{
|
||||
EnsurePollingStarted();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let an auth-state notification tear down the circuit: the worst
|
||||
// case is that this block keeps its previous visibility.
|
||||
Logger.LogWarning(ex, "Pending-updates indicator could not apply an authentication state change.");
|
||||
return;
|
||||
}
|
||||
|
||||
await RenderStateAsync();
|
||||
}
|
||||
|
||||
protected override Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (!firstRender || !_visible || _disposed)
|
||||
if (firstRender && _visible && !_disposed)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
EnsurePollingStarted();
|
||||
}
|
||||
|
||||
// Deliberately NOT awaited here and deliberately not in OnInitializedAsync:
|
||||
// a cold fleet flatten takes seconds, and this component sits in the layout —
|
||||
// awaiting it would hold the first render of every page behind it. The rail
|
||||
// paints "Checking for updates…" and fills in when the snapshot lands.
|
||||
_ = LoadAsync();
|
||||
|
||||
_timer = new Timer(
|
||||
_ => _ = _pollGate.RunAsync(
|
||||
LoadAsync,
|
||||
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed.")),
|
||||
null,
|
||||
PollInterval,
|
||||
PollInterval);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void EnsurePollingStarted()
|
||||
{
|
||||
lock (_timerLock)
|
||||
{
|
||||
if (_disposed || _timer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timer = new Timer(_ => _ = RunLoadAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
// Deliberately NOT awaited and deliberately not done in OnInitializedAsync:
|
||||
// a cold fleet flatten takes seconds, and this component sits in the layout —
|
||||
// awaiting it would hold the first render of every page behind it. The rail
|
||||
// paints "Checking for updates…" and fills in when the snapshot lands. The
|
||||
// first load goes through the same gate as a tick so nothing can escape as
|
||||
// an unobserved faulted task.
|
||||
_ = RunLoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam: runs one poll cycle exactly as the timer tick does, through the
|
||||
/// same gate. Exists so a test can pin the load-bearing cost property — that a
|
||||
/// poll resolves no authentication state — without waiting out the interval.
|
||||
/// </summary>
|
||||
internal Task PollOnceAsync() => RunLoadAsync();
|
||||
|
||||
private Task RunLoadAsync() => _pollGate.RunAsync(
|
||||
LoadAsync,
|
||||
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed."));
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
if (_disposed || !_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -149,12 +252,18 @@
|
||||
var snapshot = await PendingUpdates.GetAsync();
|
||||
|
||||
// A site-scoped operator's totals must never include a site they cannot
|
||||
// see — same rule Topology and the Sites page apply.
|
||||
if (!await SiteScope.IsSystemWideAsync())
|
||||
// see — same rule Topology and the Sites page apply. The scope was
|
||||
// resolved from the principal, so narrowing costs nothing here.
|
||||
var scope = _scopedSiteIds;
|
||||
if (scope is not null)
|
||||
{
|
||||
snapshot = snapshot.ForSites(await SiteScope.PermittedSiteIdsAsync());
|
||||
snapshot = snapshot.ForSites(scope);
|
||||
}
|
||||
|
||||
// These writes happen off the renderer's synchronization context by
|
||||
// design: the fleet read must not occupy the UI thread, PollGate's
|
||||
// single-flight guarantees there is never a second concurrent writer,
|
||||
// and only the RE-RENDER is marshalled back via InvokeAsync below.
|
||||
_snapshot = snapshot;
|
||||
_error = null;
|
||||
_lastLoggedError = null;
|
||||
@@ -192,14 +301,21 @@
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Renderer already disposed — same race, different throw site.
|
||||
// Same race, earlier throw site: dispatching to a renderer whose circuit
|
||||
// is already tearing down throws this before the ObjectDisposedException
|
||||
// path is reachable.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
|
||||
|
||||
lock (_timerLock)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user