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:
Joseph Doherty
2026-11-12 20:31:51 -05:00
parent 139a43fb25
commit 9b0720af9c
16 changed files with 919 additions and 47 deletions
@@ -6,6 +6,7 @@ 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;
@@ -78,4 +79,103 @@ public class CircuitRevocationRegressionTests
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>());
}
}
@@ -231,6 +231,32 @@ public sealed class EndpointExtensionsTests : IDisposable
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
/// <summary>
/// Auth 0.4.4 <c>ApiKeyFailure.StoreUnavailable</c>: a key store that throws is a retryable 503,
/// never a 401 (which would blame the caller's key) and never an unhandled 500.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task KeyStoreThrows_Returns503_NotUnauthorized()
{
var method = SeedMethod(1, "echo", "return 1;");
var throwingStore = Substitute.For<IApiKeyStore>();
throwingStore.FindByKeyIdAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<Task<ApiKeyRecord?>>(_ => throw new InvalidOperationException("store down"));
using var host = await BuildHostAsync(method, additionalServices: services =>
{
services.RemoveAll<IApiKeyStore>();
services.AddSingleton(throwingStore);
});
var client = host.GetTestClient();
var response = await client.SendAsync(BuildPost("echo", "{}", "sbk_key1_" + new string('a', 43)));
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
Assert.Contains("SERVICE_UNAVAILABLE", await response.Content.ReadAsStringAsync());
}
/// <summary>Verifies that a whitespace Authorization header with a valid X-API-Key returns 200.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -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
@@ -0,0 +1,380 @@
using System.Security.Claims;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.Security.Tests;
/// <summary>
/// ScadaBridge#46: the mid-session LDAP group re-query. Pins the cadence (its own anchor, independent
/// of the role-mapping threshold and of forced circuit refreshes), the failure policy (directory
/// unavailable keeps stored groups on every path; a directory answer that would refuse a new sign-in
/// ends the session on every path), the per-node answer cache and the switches.
/// </summary>
public class DirectoryGroupRefreshTests
{
private static readonly DateTimeOffset Start = new(2026, 11, 12, 9, 0, 0, TimeSpan.Zero);
private sealed class Clock(DateTimeOffset start) : TimeProvider
{
private DateTimeOffset _now = start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan by) => _now += by;
}
/// <summary>Maps each group to a role by name (Admins→Administrator, Designers→Designer, else Viewer).</summary>
private sealed class NamingMapper : IGroupRoleMapper<string>
{
public int Calls { get; private set; }
public IReadOnlyList<string>? LastGroups { get; private set; }
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
{
Calls++;
LastGroups = groups;
var roles = groups.Select(g => g switch
{
"SCADA-Admins" => Roles.Administrator,
"SCADA-Designers" => Roles.Designer,
_ => Roles.Viewer,
}).Distinct().ToList();
var result = new RoleMappingResult(roles, [], IsSystemWideDeployment: true);
return Task.FromResult(new GroupRoleMapping<string>(roles, Scope: result));
}
}
private sealed class FakeLookup : ILdapGroupLookup
{
public Func<string, LdapGroupLookupResult> Answer { get; set; } =
_ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable);
public Exception? Throw { get; set; }
public List<string> Calls { get; } = new();
public Task<LdapGroupLookupResult> GetUserGroupsAsync(string username, CancellationToken ct)
{
Calls.Add(username);
if (Throw is not null) throw Throw;
return Task.FromResult(Answer(username));
}
}
private static SecurityOptions Opts(Action<LdapGroupRefreshOptions>? tweak = null)
{
var o = new SecurityOptions
{
JwtSigningKey = "this-is-a-test-signing-key-for-hmac-sha256-must-be-long-enough",
IdleTimeoutMinutes = 30,
RoleRefreshThresholdMinutes = 15,
};
o.LdapGroupRefresh.CacheSeconds = 0; // most tests count lookups; the cache has its own tests
tweak?.Invoke(o.LdapGroupRefresh);
return o;
}
private sealed record Rig(CookieSessionValidator Sut, FakeLookup Lookup, NamingMapper Mapper, Clock Clock, DirectoryGroupRefresher Refresher);
private static Rig Build(SecurityOptions? options = null, bool withLookup = true)
{
var clock = new Clock(Start);
var lookup = new FakeLookup();
var mapper = new NamingMapper();
var opts = Options.Create(options ?? Opts());
var refresher = new DirectoryGroupRefresher(opts, clock, NullLogger<DirectoryGroupRefresher>.Instance, withLookup ? lookup : null);
var sut = new CookieSessionValidator(mapper, opts, clock, NullLogger<CookieSessionValidator>.Instance, activity: null, directory: refresher);
return new Rig(sut, lookup, mapper, clock, refresher);
}
private static ClaimsPrincipal Login(IReadOnlyList<string> groups, DateTimeOffset? at = null) =>
SessionClaimBuilder.Build("alice", "Alice", groups, new RoleMappingResult([Roles.Administrator], [], true), at ?? Start);
private static LdapGroupLookupResult FoundWith(params string[] groups) => LdapGroupLookupResult.Found("alice", "Alice", groups);
private static IReadOnlyList<string> RolesOf(ClaimsPrincipal p) => p.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
private static string? GroupAnchor(ClaimsPrincipal p) => p.FindFirst(JwtTokenService.LastGroupRefreshClaimType)?.Value;
// ---- the change lands mid-session ----
[Fact]
public async Task DueRefresh_ReReadsGroupsFromTheDirectory_AndReMapsRoles()
{
var rig = Build();
rig.Lookup.Answer = _ => FoundWith("SCADA-Designers");
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Equal(["alice"], rig.Lookup.Calls);
Assert.Equal(["SCADA-Designers"], rig.Mapper.LastGroups);
Assert.Equal([Roles.Designer], RolesOf(result.Principal!));
Assert.Equal(["SCADA-Designers"], SessionClaimBuilder.ReadGroups(result.Principal!));
Assert.Equal((Start + TimeSpan.FromMinutes(16)).ToString("o"), GroupAnchor(result.Principal!));
}
[Fact]
public async Task NotDue_RoleRefreshUsesStoredGroups_WithoutAskingTheDirectory()
{
var rig = Build(Opts(o => o.IntervalMinutes = 60));
rig.Lookup.Answer = _ => FoundWith("SCADA-Designers");
rig.Clock.Advance(TimeSpan.FromMinutes(16)); // role refresh due, group refresh not
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Empty(rig.Lookup.Calls);
Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups);
Assert.Equal(Start.ToString("o"), GroupAnchor(result.Principal!)); // anchor carried, not advanced
}
[Fact]
public async Task ShorterGroupInterval_TriggersARefreshBeforeTheRoleThreshold()
{
var rig = Build(Opts(o => o.IntervalMinutes = 5));
rig.Lookup.Answer = _ => FoundWith("SCADA-Viewers");
rig.Clock.Advance(TimeSpan.FromMinutes(6)); // role refresh (15) not due
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Single(rig.Lookup.Calls);
Assert.Equal([Roles.Viewer], RolesOf(result.Principal!));
}
[Fact]
public async Task ForcedCircuitRefreshes_AskTheDirectoryOncePerInterval()
{
var rig = Build();
rig.Lookup.Answer = _ => FoundWith("SCADA-Admins");
var principal = Login(["SCADA-Admins"]);
// Inside the interval: a forced (circuit) refresh re-maps but never queries.
rig.Clock.Advance(TimeSpan.FromMinutes(5));
principal = (await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false)).Principal!;
Assert.Empty(rig.Lookup.Calls);
// Past it: one query, then the rebuilt principal's advanced anchor stops repeats.
rig.Clock.Advance(TimeSpan.FromMinutes(11));
principal = (await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false)).Principal!;
rig.Clock.Advance(TimeSpan.FromSeconds(10));
await rig.Sut.ValidateAsync(principal, forceRefresh: true, recordActivity: false, failClosed: true);
Assert.Single(rig.Lookup.Calls);
}
[Fact]
public async Task CookieIssuedBeforeTheFeature_WithNoAnchor_IsDue()
{
var rig = Build();
rig.Lookup.Answer = _ => FoundWith("SCADA-Designers");
var old = Login(["SCADA-Admins"]);
var identity = (ClaimsIdentity)old.Identity!;
identity.RemoveClaim(identity.FindFirst(JwtTokenService.LastGroupRefreshClaimType)!);
rig.Clock.Advance(TimeSpan.FromMinutes(1));
var result = await rig.Sut.ValidateAsync(old);
Assert.Single(rig.Lookup.Calls);
Assert.Equal([Roles.Designer], RolesOf(result.Principal!));
}
[Fact]
public void Login_StampsTheGroupAnchor()
=> Assert.Equal(Start.ToString("o"), GroupAnchor(Login(["SCADA-Admins"])));
// ---- failure policy ----
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DirectoryUnavailable_KeepsStoredGroups_OnEveryPath(bool failClosed)
{
var rig = Build();
rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable);
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: failClosed, failClosed: failClosed);
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups);
Assert.Equal([Roles.Administrator], RolesOf(result.Principal!));
// The attempt still advances the anchor: an outage costs one query per interval, not per request.
Assert.Equal((Start + TimeSpan.FromMinutes(16)).ToString("o"), GroupAnchor(result.Principal!));
}
[Fact]
public async Task LookupThatThrows_IsTreatedAsUnavailable()
{
var rig = Build();
rig.Lookup.Throw = new InvalidOperationException("boom");
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), failClosed: true, forceRefresh: true);
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups);
}
public static TheoryData<LdapGroupLookupStatus, bool> Ending => new()
{
{ LdapGroupLookupStatus.UserNotFound, false },
{ LdapGroupLookupStatus.UserNotFound, true },
{ LdapGroupLookupStatus.UserDisabled, false },
{ LdapGroupLookupStatus.UserDisabled, true },
{ LdapGroupLookupStatus.AmbiguousUser, false },
{ LdapGroupLookupStatus.AmbiguousUser, true },
};
[Theory]
[MemberData(nameof(Ending))]
public async Task DirectorySaysTheUserCannotSignIn_EndsTheSession(LdapGroupLookupStatus status, bool failClosed)
{
var rig = Build();
rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(status);
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: failClosed, failClosed: failClosed);
Assert.Equal(SessionValidationAction.Reject, result.Action);
Assert.Equal(0, rig.Mapper.Calls);
}
[Fact]
public async Task FoundWithNoGroups_EndsTheSession()
{
var rig = Build();
rig.Lookup.Answer = _ => FoundWith();
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.Equal(SessionValidationAction.Reject, result.Action);
}
[Theory]
[InlineData(LdapGroupLookupStatus.NotSupported)]
[InlineData(LdapGroupLookupStatus.ProviderDisabled)]
public async Task LookupNotPossible_KeepsStoredGroups(LdapGroupLookupStatus status)
{
var rig = Build();
rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(status);
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups);
}
// ---- switches ----
[Fact]
public async Task Disabled_NeverAsksTheDirectory()
{
var rig = Build(Opts(o => o.Enabled = false));
rig.Lookup.Answer = _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.UserDisabled);
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]), forceRefresh: true, failClosed: true);
Assert.Equal(SessionValidationAction.Replace, result.Action);
Assert.Empty(rig.Lookup.Calls);
Assert.False(rig.Sut.IsGroupRefreshDue(Login(["x"]), Start + TimeSpan.FromDays(1)));
}
[Fact]
public async Task NoLookupRegistered_IsInert()
{
var rig = Build(withLookup: false);
rig.Clock.Advance(TimeSpan.FromMinutes(16));
var result = await rig.Sut.ValidateAsync(Login(["SCADA-Admins"]));
Assert.False(rig.Refresher.IsActive);
Assert.Equal(["SCADA-Admins"], rig.Mapper.LastGroups);
Assert.Equal(SessionValidationAction.Replace, result.Action);
}
// ---- the per-node answer cache ----
[Fact]
public async Task Cache_CollapsesRepeatQueriesForOneUser_WithinItsWindow()
{
var rig = Build(Opts(o => o.CacheSeconds = 60));
rig.Lookup.Answer = _ => FoundWith("SCADA-Admins");
await rig.Refresher.RefreshAsync("alice", default);
rig.Clock.Advance(TimeSpan.FromSeconds(30));
await rig.Refresher.RefreshAsync("ALICE", default);
Assert.Single(rig.Lookup.Calls);
rig.Clock.Advance(TimeSpan.FromSeconds(31));
await rig.Refresher.RefreshAsync("alice", default);
Assert.Equal(2, rig.Lookup.Calls.Count);
}
[Fact]
public async Task CacheZero_AsksEveryTime()
{
var rig = Build(Opts(o => o.CacheSeconds = 0));
rig.Lookup.Answer = _ => FoundWith("SCADA-Admins");
await rig.Refresher.RefreshAsync("alice", default);
await rig.Refresher.RefreshAsync("alice", default);
Assert.Equal(2, rig.Lookup.Calls.Count);
}
// ---- options validation ----
[Theory]
[InlineData(true, 0, 60, false)]
[InlineData(true, 1, 60, true)]
[InlineData(false, 0, 60, true)]
[InlineData(true, 15, -1, false)]
[InlineData(true, 15, 0, true)]
public void Validator_ChecksIntervalAndCache(bool enabled, int interval, int cache, bool valid)
{
var o = Opts(g => { g.Enabled = enabled; g.IntervalMinutes = interval; g.CacheSeconds = cache; });
var result = new SecurityOptionsValidator().Validate(null, o);
Assert.Equal(valid, result.Succeeded);
}
[Fact]
public void Defaults_AreOnFifteenMinutesSixtySeconds()
{
var o = new SecurityOptions().LdapGroupRefresh;
Assert.True(o.Enabled);
Assert.Equal(15, o.IntervalMinutes);
Assert.Equal(60, o.CacheSeconds);
}
// ---- composition ----
[Fact]
public void AddSecurity_PlusAddZbLdapAuth_ResolvesAnActiveRefresherIntoTheValidator()
{
var config = new Microsoft.Extensions.Configuration.ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Ldap:Server"] = "ldap.example.com",
["Ldap:SearchBase"] = "dc=example,dc=com",
["Ldap:ServiceAccountDn"] = "cn=svc,dc=example,dc=com",
}).Build();
var services = new ServiceCollection().AddLogging();
ZB.MOM.WW.Auth.AspNetCore.ServiceCollectionExtensions.AddZbLdapAuth(services, config, "Ldap");
services.AddSecurity();
services.AddScoped<IGroupRoleMapper<string>, NamingMapper>(); // the real one needs the DB repository
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
Assert.True(provider.GetRequiredService<DirectoryGroupRefresher>().IsActive);
Assert.NotNull(scope.ServiceProvider.GetRequiredService<CookieSessionValidator>());
}
}