fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state
This commit is contained in:
@@ -7,23 +7,37 @@ namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges ASP.NET Core cookie authentication with Blazor Server's auth state.
|
||||
/// The cookie middleware has already validated and decrypted the cookie by the time
|
||||
/// the Blazor circuit is established, so we just read HttpContext.User.
|
||||
/// <para>
|
||||
/// The cookie middleware validates and decrypts the cookie during the initial
|
||||
/// HTTP request that establishes the Blazor circuit. This provider is registered
|
||||
/// <c>Scoped</c>, so it is constructed within that request's DI scope while
|
||||
/// <see cref="IHttpContextAccessor.HttpContext"/> is still valid. We snapshot
|
||||
/// the authenticated principal <b>once</b> in the constructor and serve that
|
||||
/// snapshot for the lifetime of the circuit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// We must NOT read <see cref="IHttpContextAccessor"/> on every
|
||||
/// <see cref="GetAuthenticationStateAsync"/> call (CentralUI-004): for the
|
||||
/// lifetime of a long-lived SignalR circuit <c>HttpContext</c> is <c>null</c>
|
||||
/// (or, worse, a stale/foreign context), so a later re-evaluation —
|
||||
/// e.g. <c><AuthorizeView></c> re-rendering — would otherwise see an
|
||||
/// unauthenticated principal and render the wrong UI.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class CookieAuthenticationStateProvider : ServerAuthenticationStateProvider
|
||||
{
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly Task<AuthenticationState> _circuitAuthState;
|
||||
|
||||
public CookieAuthenticationStateProvider(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
// Snapshot the principal at circuit-construction time. HttpContext is
|
||||
// valid here (initial HTTP request) and will not be afterwards.
|
||||
var user = httpContextAccessor.HttpContext?.User
|
||||
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
_circuitAuthState = Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
|
||||
public override Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||
{
|
||||
var user = _httpContextAccessor.HttpContext?.User
|
||||
?? new ClaimsPrincipal(new ClaimsIdentity());
|
||||
|
||||
return Task.FromResult(new AuthenticationState(user));
|
||||
}
|
||||
=> _circuitAuthState;
|
||||
}
|
||||
|
||||
93
src/ScadaLink.CentralUI/Auth/SiteScopeService.cs
Normal file
93
src/ScadaLink.CentralUI/Auth/SiteScopeService.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using ScadaLink.Commons.Entities.Sites;
|
||||
using ScadaLink.Security;
|
||||
|
||||
namespace ScadaLink.CentralUI.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the set of sites the current user is permitted to operate on, from
|
||||
/// the <c>SiteId</c> claims attached at login (CentralUI-002).
|
||||
/// <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 Deployment user with no <c>SiteId</c> claim — and any Admin/Design user — is
|
||||
/// system-wide.
|
||||
/// </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;
|
||||
private (bool IsSystemWide, IReadOnlySet<int> Sites)? _cached;
|
||||
|
||||
public SiteScopeService(AuthenticationStateProvider authStateProvider)
|
||||
{
|
||||
_authStateProvider = authStateProvider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the user is not restricted to a site subset (no <c>SiteId</c>
|
||||
/// claims). System-wide users see and act on every site.
|
||||
/// </summary>
|
||||
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>
|
||||
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>
|
||||
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>
|
||||
public async Task<bool> IsSiteAllowedAsync(int siteId)
|
||||
{
|
||||
var (isSystemWide, allowed) = await ResolveAsync();
|
||||
return isSystemWide || allowed.Contains(siteId);
|
||||
}
|
||||
|
||||
private async Task<(bool IsSystemWide, IReadOnlySet<int> Sites)> ResolveAsync()
|
||||
{
|
||||
if (_cached is { } cached)
|
||||
return cached;
|
||||
|
||||
var state = await _authStateProvider.GetAuthenticationStateAsync();
|
||||
var siteClaims = state.User.FindAll(JwtTokenService.SiteIdClaimType);
|
||||
|
||||
var ids = new HashSet<int>();
|
||||
foreach (var claim in siteClaims)
|
||||
{
|
||||
if (int.TryParse(claim.Value, out var id))
|
||||
ids.Add(id);
|
||||
}
|
||||
|
||||
// No SiteId claims => system-wide. This mirrors SiteScopeAuthorizationHandler:
|
||||
// absence of scope rules means an unrestricted deployer.
|
||||
var result = (IsSystemWide: ids.Count == 0, Sites: (IReadOnlySet<int>)ids);
|
||||
_cached = result;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user