feat(security): re-query LDAP group membership for active sessions (#46)
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
This commit is contained in:
@@ -3,6 +3,7 @@ using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.IntegrationTests;
|
||||
|
||||
@@ -180,6 +181,51 @@ public class AuthFlowTests : IClassFixture<ScadaBridgeWebApplicationFactory>
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ScadaBridge#46 against the live GLAuth pair: an active session whose stored groups are stale
|
||||
/// is re-read through the real Auth 0.5.0 <c>ILdapGroupLookup</c> (service account), so the
|
||||
/// directory's groups replace the stored ones without the user's password; a deleted account
|
||||
/// ends the session.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Trait("Category", "Integration")]
|
||||
[Fact]
|
||||
public async Task ActiveSession_GroupsAreReReadFromTheDirectory_AndAnUnknownUserIsEnded()
|
||||
{
|
||||
if (!await IsLdapAvailableAsync())
|
||||
{
|
||||
return; // GLAuth not running locally — see the login test above.
|
||||
}
|
||||
|
||||
var refresher = _factory.Services.GetRequiredService<DirectoryGroupRefresher>();
|
||||
Assert.True(refresher.IsActive);
|
||||
refresher.Clear();
|
||||
|
||||
// The GLAuth "designer" account is in SCADA-Designers only; the session claims SCADA-Admins.
|
||||
var issued = DateTimeOffset.UtcNow.AddMinutes(-16);
|
||||
var stale = SessionClaimBuilder.Build("designer", "designer", ["SCADA-Admins"],
|
||||
new RoleMappingResult([Roles.Administrator], [], true), issued);
|
||||
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var validator = scope.ServiceProvider.GetRequiredService<CookieSessionValidator>();
|
||||
var result = await validator.ValidateAsync(stale);
|
||||
|
||||
Assert.Equal(SessionValidationAction.Replace, result.Action);
|
||||
Assert.Equal(["SCADA-Designers"], SessionClaimBuilder.ReadGroups(result.Principal!));
|
||||
Assert.DoesNotContain(Roles.Administrator,
|
||||
result.Principal!.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value));
|
||||
}
|
||||
|
||||
var gone = SessionClaimBuilder.Build("no-such-user-46", "x", ["SCADA-Admins"],
|
||||
new RoleMappingResult([Roles.Administrator], [], true), issued);
|
||||
using (var scope = _factory.Services.CreateScope())
|
||||
{
|
||||
var validator = scope.ServiceProvider.GetRequiredService<CookieSessionValidator>();
|
||||
Assert.Equal(SessionValidationAction.Reject, (await validator.ValidateAsync(gone)).Action);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> IsLdapAvailableAsync()
|
||||
{
|
||||
try
|
||||
|
||||
Reference in New Issue
Block a user