9b0720af9c
Auth 0.5.0 (all five packages) adds the passwordless ILdapGroupLookup. A new singleton DirectoryGroupRefresher asks it for a session's current groups once the new zb:lastgrouprefresh anchor (stamped at login) is older than ScadaBridge:Security:LdapGroupRefresh:IntervalMinutes (default 15, Enabled default true, CacheSeconds 60 per-node answer reuse). CookieSessionValidator replaces the stored groups and re-maps roles; this reaches HTTP and live circuits (once per interval, independent of the 10 s circuit refresh). Failure policy: directory unavailable keeps the stored groups (warn, all paths incl. fail-closed mutation checks); user not found / disabled / ambiguous / no groups ends the session. Inert under BindMode=User (NotSupported), LDAP disabled, or DisableLogin. Auth 0.4.4 ApiKeyFailure.StoreUnavailable now answers 503 SERVICE_UNAVAILABLE on the inbound API instead of a misleading 401. Docs: Component-Security.md, components/Security.md, Component-Host.md, register row 23 closed. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
182 lines
10 KiB
C#
182 lines
10 KiB
C#
using Microsoft.AspNetCore.Components.Authorization;
|
|
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Options;
|
|
using NSubstitute;
|
|
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
|
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;
|
|
|
|
public class CircuitRevocationRegressionTests
|
|
{
|
|
/// <summary>Verifies that an explicit server auto-login does not remap the synthetic cookie principal.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task ExplicitServerAutoLogin_DoesNotRemapSyntheticCookiePrincipal()
|
|
{
|
|
var initial = SessionClaimBuilder.Build("developer", "Developer", [],
|
|
new RoleMappingResult(Roles.All, [], true), DateTimeOffset.UtcNow);
|
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(new GroupRoleMapping<string>([], Scope: new RoleMappingResult([], [], false)));
|
|
var services = new ServiceCollection().AddLogging();
|
|
services.AddSingleton(mapper);
|
|
services.AddSingleton<TimeProvider>(TimeProvider.System);
|
|
services.AddSingleton(Options.Create(new SecurityOptions()));
|
|
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = true }));
|
|
services.AddScoped<CookieSessionValidator>();
|
|
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
|
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
|
using var provider = services.BuildServiceProvider();
|
|
using var scope = provider.CreateScope();
|
|
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
|
|
var state = await auth.GetAuthoritativeStateAsync();
|
|
Assert.Contains(state.User.Claims, claim => claim.Type == JwtTokenService.RoleClaimType && claim.Value == Roles.Administrator);
|
|
await mapper.DidNotReceive().MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>Verifies that the same circuit rechecks role mapping and site scope after a grant is revoked.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SameCircuit_RechecksMappingAndScope_AfterGrantRevocation()
|
|
{
|
|
var mapping = new RoleMappingResult([Roles.Deployer, Roles.Administrator], ["1"], 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, DateTimeOffset.UtcNow);
|
|
var services = new ServiceCollection().AddLogging();
|
|
services.AddSingleton(mapper);
|
|
var transaction = Substitute.For<IConfigurationCommandTransaction>();
|
|
services.AddSingleton(transaction);
|
|
services.AddScoped<ConfigurationCommandService>();
|
|
services.AddSingleton<TimeProvider>(TimeProvider.System);
|
|
services.AddSingleton(Options.Create(new SecurityOptions()));
|
|
services.AddScoped<CookieSessionValidator>();
|
|
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
|
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
|
using var provider = services.BuildServiceProvider();
|
|
using var scope = provider.CreateScope();
|
|
var auth = scope.ServiceProvider.GetRequiredService<AuthenticationStateProvider>();
|
|
var sites = new SiteScopeService(auth);
|
|
Assert.True(await sites.IsSiteAllowedAsync(1));
|
|
mapping = new RoleMappingResult([Roles.Viewer], ["2"], false);
|
|
// 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.False(await sites.IsSiteAllowedAsync(1));
|
|
Assert.True(await sites.IsSiteAllowedAsync(2));
|
|
// The already-created circuit facade must deny before starting any save transaction.
|
|
var commands = scope.ServiceProvider.GetRequiredService<ConfigurationCommandService>();
|
|
await Assert.ThrowsAsync<UnauthorizedAccessException>(() => commands.ExecuteAsync(new CreateRoleMappingCommand("blocked", Roles.Viewer)));
|
|
await transaction.DidNotReceive().ExecuteAsync(Arg.Any<Func<IServiceProvider, Task<object?>>>());
|
|
}
|
|
|
|
// ---- ScadaBridge#46: directory group re-query reaches a live circuit ----
|
|
|
|
private static (AuthenticationStateProvider Auth, ILdapGroupLookup Lookup, ServiceProvider Provider) DirectoryCircuit(
|
|
IReadOnlyList<string> loginGroups, bool disableLogin = false)
|
|
{
|
|
// Session issued 16 minutes ago, so the 15-minute group re-query is due on the first read.
|
|
var issued = DateTimeOffset.UtcNow.AddMinutes(-16);
|
|
var initial = SessionClaimBuilder.Build("alice", "Alice", loginGroups,
|
|
new RoleMappingResult([Roles.Administrator], [], true), issued);
|
|
var mapper = Substitute.For<IGroupRoleMapper<string>>();
|
|
mapper.MapAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
|
.Returns(call =>
|
|
{
|
|
var groups = call.Arg<IReadOnlyList<string>>();
|
|
var roles = groups.Contains("SCADA-Admins") ? new[] { Roles.Administrator } : new[] { Roles.Viewer };
|
|
return new GroupRoleMapping<string>(roles, Scope: new RoleMappingResult(roles, [], true));
|
|
});
|
|
var lookup = Substitute.For<ILdapGroupLookup>();
|
|
var services = new ServiceCollection().AddLogging();
|
|
services.AddSingleton(mapper);
|
|
services.AddSingleton(lookup);
|
|
services.AddSingleton<TimeProvider>(TimeProvider.System);
|
|
// Cache off, so "no second query" below is proven by the session's advanced anchor, not the cache.
|
|
services.AddSingleton(Options.Create(new SecurityOptions { LdapGroupRefresh = { CacheSeconds = 0 } }));
|
|
services.AddSingleton(Options.Create(new ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions { DisableLogin = disableLogin }));
|
|
services.AddSingleton<DirectoryGroupRefresher>();
|
|
services.AddScoped<CookieSessionValidator>();
|
|
services.AddSingleton<IHttpContextAccessor>(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
|
|
services.AddScoped<AuthenticationStateProvider, CookieAuthenticationStateProvider>();
|
|
var provider = services.BuildServiceProvider();
|
|
return (provider.CreateScope().ServiceProvider.GetRequiredService<AuthenticationStateProvider>(), lookup, provider);
|
|
}
|
|
|
|
/// <summary>A directory group removal lands in an open circuit without re-login (ScadaBridge#46).</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SameCircuit_PicksUpDirectoryGroupRemoval_WithoutReLogin()
|
|
{
|
|
var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
|
|
using var _ = provider;
|
|
lookup.GetUserGroupsAsync("alice", Arg.Any<CancellationToken>())
|
|
.Returns(LdapGroupLookupResult.Found("alice", "Alice", ["SCADA-Viewers"]));
|
|
|
|
var state = await auth.GetAuthoritativeStateAsync();
|
|
|
|
var roles = state.User.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
|
Assert.Equal([Roles.Viewer], roles);
|
|
Assert.True(state.User.Identity!.IsAuthenticated);
|
|
await lookup.Received(1).GetUserGroupsAsync("alice", Arg.Any<CancellationToken>());
|
|
|
|
// The circuit's rebuilt principal carries the advanced anchor: the next read does not re-query.
|
|
await auth.GetAuthoritativeStateAsync();
|
|
await lookup.Received(1).GetUserGroupsAsync("alice", Arg.Any<CancellationToken>());
|
|
}
|
|
|
|
/// <summary>A user disabled in the directory loses the circuit's authentication; a gate then denies.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SameCircuit_DirectoryDisabledUser_BecomesAnonymous()
|
|
{
|
|
var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
|
|
using var _ = provider;
|
|
lookup.GetUserGroupsAsync("alice", Arg.Any<CancellationToken>())
|
|
.Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.UserDisabled));
|
|
|
|
var state = await auth.GetAuthoritativeStateAsync();
|
|
|
|
Assert.False(state.User.Identity?.IsAuthenticated ?? false);
|
|
Assert.Empty(state.User.FindAll(JwtTokenService.RoleClaimType));
|
|
}
|
|
|
|
/// <summary>An LDAP outage keeps the circuit's roles, even on the fail-closed authoritative path.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task SameCircuit_DirectoryUnavailable_KeepsRoles()
|
|
{
|
|
var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
|
|
using var _ = provider;
|
|
lookup.GetUserGroupsAsync("alice", Arg.Any<CancellationToken>())
|
|
.Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable));
|
|
|
|
var state = await auth.GetAuthoritativeStateAsync();
|
|
|
|
Assert.Contains(state.User.FindAll(JwtTokenService.RoleClaimType), c => c.Value == Roles.Administrator);
|
|
}
|
|
|
|
/// <summary>DisableLogin (auto-login) never queries the directory.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[Fact]
|
|
public async Task DisableLogin_NeverQueriesTheDirectory()
|
|
{
|
|
var (auth, lookup, provider) = DirectoryCircuit([], disableLogin: true);
|
|
using var _ = provider;
|
|
|
|
await auth.GetAuthoritativeStateAsync();
|
|
|
|
await lookup.DidNotReceive().GetUserGroupsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
|
|
}
|
|
}
|