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
@@ -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]