fix(centralui-auth): memoize non-authoritative circuit auth-state reads for 10 s (review 2026-10-21 07/P-3)
GetAuthenticationStateAsync (display reads: current username, nav role checks) used to force a full revalidation + DB role re-map on every call. It now reuses the circuit's last revalidation for NonAuthoritativeRefreshInterval (10 s); GetAuthoritativeStateAsync (mutation gates, UI-13) still re-maps every time and fails closed. The revocation regression test now asserts through the authoritative read, which is what every gate uses.
This commit is contained in:
@@ -15,28 +15,58 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
|||||||
/// against current server state. Fresh scopes avoid sharing the circuit's DbContext.
|
/// against current server state. Fresh scopes avoid sharing the circuit's DbContext.
|
||||||
/// Never reads IHttpContextAccessor after construction.
|
/// Never reads IHttpContextAccessor after construction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="GetAuthoritativeStateAsync"/> (mutation gates, UI-13) re-maps the stored LDAP
|
||||||
|
/// groups on every call and fails closed on a mapping fault.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="GetAuthenticationStateAsync"/> (display reads — the current username for a save
|
||||||
|
/// button, a nav-menu role check) is memoized per circuit for
|
||||||
|
/// <see cref="NonAuthoritativeRefreshInterval"/> (review 2026-10-21 07/P-3): within that window
|
||||||
|
/// it returns the state of the last revalidation, authoritative or not, instead of running the
|
||||||
|
/// role mapping again. A revoked grant can therefore linger on a display surface for at most
|
||||||
|
/// that window, while every mutation still revalidates. The underlying mapping itself is
|
||||||
|
/// served from <see cref="RoleMappingCache"/>, so even a revalidation is normally DB-free.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||||
{
|
{
|
||||||
|
/// <summary>How long a non-authoritative read may reuse the circuit's last revalidated state.</summary>
|
||||||
|
public static readonly TimeSpan NonAuthoritativeRefreshInterval = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
private AuthenticationState _state;
|
private AuthenticationState _state;
|
||||||
private readonly IServiceScopeFactory? _scopes;
|
private readonly IServiceScopeFactory? _scopes;
|
||||||
private readonly bool _autoLogin;
|
private readonly bool _autoLogin;
|
||||||
|
private readonly TimeProvider _clock;
|
||||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||||
|
|
||||||
|
// UTC ticks of the last completed revalidation; 0 = never. Read outside the gate.
|
||||||
|
private long _lastRefreshTicks;
|
||||||
|
|
||||||
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
|
/// <summary>Captures identity and the factory used for subsequent server-side revalidation.</summary>
|
||||||
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
|
/// <param name="httpContextAccessor">Supplies the user captured at construction.</param>
|
||||||
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
|
/// <param name="scopes">Factory for fresh scopes used during revalidation.</param>
|
||||||
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
|
/// <param name="autoLogin">Options indicating whether login is disabled (auto-login).</param>
|
||||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null)
|
/// <param name="clock">Clock for the non-authoritative memo window; defaults to the system clock.</param>
|
||||||
|
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor, IServiceScopeFactory? scopes = null, IOptions<AuthDisableLoginOptions>? autoLogin = null, TimeProvider? clock = null)
|
||||||
{
|
{
|
||||||
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
|
_state = new AuthenticationState(httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal(new ClaimsIdentity()));
|
||||||
_scopes = scopes;
|
_scopes = scopes;
|
||||||
// Trusted startup configuration, already checked by DisableLoginGuard. The
|
// Trusted startup configuration, already checked by DisableLoginGuard. The
|
||||||
// auto-login handler deliberately uses the Cookies scheme with no LDAP groups.
|
// auto-login handler deliberately uses the Cookies scheme with no LDAP groups.
|
||||||
_autoLogin = autoLogin?.Value.DisableLogin == true;
|
_autoLogin = autoLogin?.Value.DisableLogin == true;
|
||||||
|
_clock = clock ?? TimeProvider.System;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override Task<AuthenticationState> GetAuthenticationStateAsync() => RefreshAsync(failClosed: false);
|
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
|
{
|
||||||
|
var last = Interlocked.Read(ref _lastRefreshTicks);
|
||||||
|
if (last != 0 && _clock.GetUtcNow().UtcTicks - last < NonAuthoritativeRefreshInterval.Ticks)
|
||||||
|
return Task.FromResult(Volatile.Read(ref _state));
|
||||||
|
return RefreshAsync(failClosed: false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
|
/// <summary>Requires a current mapping before a mutation; mapping faults deny rather than reuse old grants.</summary>
|
||||||
/// <returns>The authoritative authentication state.</returns>
|
/// <returns>The authoritative authentication state.</returns>
|
||||||
@@ -61,7 +91,8 @@ public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvid
|
|||||||
_ => _state.User,
|
_ => _state.User,
|
||||||
};
|
};
|
||||||
changed = Authority(_state.User) != Authority(user);
|
changed = Authority(_state.User) != Authority(user);
|
||||||
_state = state = new AuthenticationState(user);
|
Volatile.Write(ref _state, state = new AuthenticationState(user));
|
||||||
|
Interlocked.Exchange(ref _lastRefreshTicks, _clock.GetUtcNow().UtcTicks);
|
||||||
}
|
}
|
||||||
finally { _gate.Release(); }
|
finally { _gate.Release(); }
|
||||||
if (changed) NotifyAuthenticationStateChanged(Task.FromResult(state));
|
if (changed) NotifyAuthenticationStateChanged(Task.FromResult(state));
|
||||||
|
|||||||
+4
-1
@@ -66,7 +66,10 @@ public class CircuitRevocationRegressionTests
|
|||||||
var sites = new SiteScopeService(auth);
|
var sites = new SiteScopeService(auth);
|
||||||
Assert.True(await sites.IsSiteAllowedAsync(1));
|
Assert.True(await sites.IsSiteAllowedAsync(1));
|
||||||
mapping = new RoleMappingResult([Roles.Viewer], ["2"], false);
|
mapping = new RoleMappingResult([Roles.Viewer], ["2"], false);
|
||||||
var refreshed = await auth.GetAuthenticationStateAsync();
|
// Authoritative read: a non-authoritative read may reuse the circuit's last
|
||||||
|
// revalidation for CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval
|
||||||
|
// (review 2026-10-21 07/P-3); every gate below is authoritative.
|
||||||
|
var refreshed = await auth.GetAuthoritativeStateAsync();
|
||||||
Assert.DoesNotContain(Roles.Deployer, refreshed.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
|
Assert.DoesNotContain(Roles.Deployer, refreshed.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
|
||||||
Assert.False(await sites.IsSiteAllowedAsync(1));
|
Assert.False(await sites.IsSiteAllowedAsync(1));
|
||||||
Assert.True(await sites.IsSiteAllowedAsync(2));
|
Assert.True(await sites.IsSiteAllowedAsync(2));
|
||||||
|
|||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
using Microsoft.AspNetCore.Components.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using NSubstitute;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||||
|
using ZB.MOM.WW.ScadaBridge.Security;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Auth;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Review 2026-10-21 07/P-3: a non-authoritative <see cref="CookieAuthenticationStateProvider.GetAuthenticationStateAsync"/>
|
||||||
|
/// no longer re-runs the role mapping on every read — it reuses the circuit's last revalidation
|
||||||
|
/// for <see cref="CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval"/> — while
|
||||||
|
/// <see cref="CookieAuthenticationStateProvider.GetAuthoritativeStateAsync"/> (mutation gates)
|
||||||
|
/// still re-maps every time.
|
||||||
|
/// </summary>
|
||||||
|
public class CookieAuthenticationStateMemoTests
|
||||||
|
{
|
||||||
|
private sealed class MutableClock : TimeProvider
|
||||||
|
{
|
||||||
|
public DateTimeOffset Now { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
public override DateTimeOffset GetUtcNow() => Now;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (CookieAuthenticationStateProvider Auth, IGroupRoleMapper<string> Mapper, MutableClock Clock, ServiceProvider Provider) Create()
|
||||||
|
{
|
||||||
|
var clock = new MutableClock();
|
||||||
|
var mapping = new RoleMappingResult([Roles.Designer], [], false);
|
||||||
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
||||||
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns(_ => new GroupRoleMapping<string>(mapping.Roles, Scope: mapping));
|
||||||
|
var initial = SessionClaimBuilder.Build("alice", "Alice", ["group"], mapping, clock.Now);
|
||||||
|
var services = new ServiceCollection().AddLogging();
|
||||||
|
services.AddSingleton(mapper);
|
||||||
|
services.AddSingleton<TimeProvider>(clock);
|
||||||
|
services.AddSingleton(Options.Create(new SecurityOptions()));
|
||||||
|
services.AddScoped<CookieSessionValidator>();
|
||||||
|
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
|
||||||
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
||||||
|
var provider = services.BuildServiceProvider();
|
||||||
|
var auth = (CookieAuthenticationStateProvider)provider.CreateScope().ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
|
||||||
|
return (auth, mapper, clock, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Repeated display reads within the window run the role mapping once.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task NonAuthoritativeReads_WithinWindow_MapOnce()
|
||||||
|
{
|
||||||
|
var (auth, mapper, _, provider) = Create();
|
||||||
|
using var _p = provider;
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Equal("alice", (await auth.GetCurrentUsernameAsync()));
|
||||||
|
|
||||||
|
await mapper.Received(1).MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>After the window the next display read revalidates again.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task NonAuthoritativeRead_AfterWindow_Revalidates()
|
||||||
|
{
|
||||||
|
var (auth, mapper, clock, provider) = Create();
|
||||||
|
using var _p = provider;
|
||||||
|
|
||||||
|
await auth.GetAuthenticationStateAsync();
|
||||||
|
clock.Now += CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval + TimeSpan.FromSeconds(1);
|
||||||
|
await auth.GetAuthenticationStateAsync();
|
||||||
|
|
||||||
|
await mapper.Received(2).MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Authoritative reads (mutation gates) always re-map, even inside the window.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
[Fact]
|
||||||
|
public async Task AuthoritativeReads_AlwaysRemap()
|
||||||
|
{
|
||||||
|
var (auth, mapper, _, provider) = Create();
|
||||||
|
using var _p = provider;
|
||||||
|
|
||||||
|
await auth.GetAuthenticationStateAsync();
|
||||||
|
await auth.GetAuthoritativeStateAsync();
|
||||||
|
await auth.GetAuthoritativeStateAsync();
|
||||||
|
|
||||||
|
await mapper.Received(3).MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user