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
381 lines
15 KiB
C#
381 lines
15 KiB
C#
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>());
|
|
}
|
|
}
|