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:
@@ -110,6 +110,20 @@ public static class EndpointExtensions
|
||||
var verification = await verifier.VerifyAsync(
|
||||
credential, httpContext.RequestAborted);
|
||||
|
||||
if (!verification.Succeeded
|
||||
&& verification.Failure == ZB.MOM.WW.Auth.Abstractions.ApiKeys.ApiKeyFailure.StoreUnavailable)
|
||||
{
|
||||
// Auth 0.4.4+: the key store itself could not be read (shared SQL Server down, SQLite
|
||||
// I/O fault). That says nothing about the caller's credential, so it is a retryable 503,
|
||||
// not a 401 that would tell an integration its key is wrong. (Before 0.4.4 the fault
|
||||
// escaped VerifyAsync as an unhandled 500.)
|
||||
ScadaBridgeTelemetry.RecordInboundApiRequest("<unauthorized>");
|
||||
logger.LogWarning(
|
||||
"Inbound API key store unavailable for method {Method}; answering 503.", methodName);
|
||||
return Error("SERVICE_UNAVAILABLE", "API key store unavailable; retry later",
|
||||
StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (!verification.Succeeded)
|
||||
{
|
||||
// 401 for any verifier failure. The failure reason is
|
||||
|
||||
@@ -37,7 +37,7 @@ public enum SessionValidationAction
|
||||
/// <summary>Leave the principal as-is (no idle timeout, no refresh due, or a refresh error we swallow).</summary>
|
||||
Keep,
|
||||
|
||||
/// <summary>The session is idle-timed-out; reject + sign out.</summary>
|
||||
/// <summary>The session is idle-timed-out, or the directory says the user can no longer sign in; reject + sign out.</summary>
|
||||
Reject,
|
||||
|
||||
/// <summary>The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.</summary>
|
||||
@@ -47,8 +47,10 @@ public enum SessionValidationAction
|
||||
/// <summary>
|
||||
/// The unit-testable core of the cookie <c>OnValidatePrincipal</c> event.
|
||||
/// Enforces the idle timeout and refreshes the session's role/scope claims from the
|
||||
/// STORED LDAP group claims via the DB-backed <see cref="RoleMapper"/> — <b>without any
|
||||
/// LDAP call</b> — picking up central role-mapping (and scope-rule) changes mid-session.
|
||||
/// session's LDAP group claims via the DB-backed <see cref="RoleMapper"/>, picking up central
|
||||
/// role-mapping (and scope-rule) changes mid-session. The groups themselves are re-read from the
|
||||
/// directory every <see cref="LdapGroupRefreshOptions.IntervalMinutes"/> through
|
||||
/// <see cref="DirectoryGroupRefresher"/> (ScadaBridge#46); in between, the STORED groups are re-mapped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -67,6 +69,14 @@ public enum SessionValidationAction
|
||||
/// mapping revoked the user's roles, the rebuilt principal reflects the loss.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Directory group re-query</b> (ScadaBridge#46): when the session's
|
||||
/// <see cref="JwtTokenService.LastGroupRefreshClaimType"/> anchor is older than the interval, the
|
||||
/// user's groups are re-read with the LDAP service account and replace the stored ones before the
|
||||
/// re-map. A directory that cannot answer keeps the stored groups (availability first); a directory
|
||||
/// answer under which a new sign-in would be refused (user gone, disabled, ambiguous, or no groups)
|
||||
/// ends the session — <see cref="SessionValidationResult.Reject"/> — on every path, authoritative or not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
|
||||
/// unreachable) preserves current grants during ordinary cookie validation.
|
||||
/// Authoritative circuit mutation checks request fail-closed validation and propagate
|
||||
@@ -80,6 +90,7 @@ public sealed class CookieSessionValidator
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<CookieSessionValidator> _logger;
|
||||
private readonly SessionActivityTracker? _activity;
|
||||
private readonly DirectoryGroupRefresher? _directory;
|
||||
|
||||
/// <summary>Initializes the validator.</summary>
|
||||
/// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
|
||||
@@ -87,12 +98,15 @@ public sealed class CookieSessionValidator
|
||||
/// <param name="timeProvider">Clock source; injected so tests can advance time deterministically.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
/// <param name="activity">Optional tracker that records session activity for idle-timeout accounting.</param>
|
||||
/// <param name="directory">Optional directory group re-query; <c>null</c> re-maps stored groups only.</param>
|
||||
public CookieSessionValidator(
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
IOptions<SecurityOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null)
|
||||
ILogger<CookieSessionValidator> logger, SessionActivityTracker? activity = null,
|
||||
DirectoryGroupRefresher? directory = null)
|
||||
{
|
||||
_directory = directory;
|
||||
_roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
|
||||
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
|
||||
@@ -139,7 +153,7 @@ public sealed class CookieSessionValidator
|
||||
var refreshed = await TryRefreshAsync(principal, now, ct, recordActivity, forceRefresh).ConfigureAwait(false);
|
||||
if (refreshed is not null)
|
||||
{
|
||||
return SessionValidationResult.Replace(refreshed);
|
||||
return refreshed.Value;
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (!failClosed)
|
||||
@@ -176,13 +190,14 @@ public sealed class CookieSessionValidator
|
||||
return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
|
||||
}
|
||||
|
||||
// Returns a rebuilt principal when the role-refresh interval has elapsed; null when
|
||||
// nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
|
||||
// identical to /auth/login.
|
||||
private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
|
||||
// Returns Replace(rebuilt principal) when a refresh ran, Reject when the directory ended the
|
||||
// session, null when nothing was due. The principal is rebuilt via SessionClaimBuilder so its
|
||||
// shape is identical to /auth/login.
|
||||
private async Task<SessionValidationResult?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct, bool recordActivity, bool forceRefresh)
|
||||
{
|
||||
var roleRefreshDue = IsRoleRefreshDue(principal, now);
|
||||
if (!roleRefreshDue && !forceRefresh)
|
||||
var groupRefreshDue = IsGroupRefreshDue(principal, now);
|
||||
if (!roleRefreshDue && !groupRefreshDue && !forceRefresh)
|
||||
{
|
||||
// No mapping refresh due. We deliberately do NOT mint a new principal just to
|
||||
// advance LastActivity: the cookie middleware's SlidingExpiration already
|
||||
@@ -203,8 +218,38 @@ public sealed class CookieSessionValidator
|
||||
}
|
||||
|
||||
var groups = SessionClaimBuilder.ReadGroups(principal);
|
||||
var groupAnchor = ReadAnchor(principal, JwtTokenService.LastGroupRefreshClaimType) ?? now;
|
||||
|
||||
// Re-run the DB-backed mapping on the STORED groups — NO LDAP call.
|
||||
// ScadaBridge#46: re-read the groups from the directory when their anchor is due. The anchor
|
||||
// advances whatever the answer — including an unavailable directory, which keeps the stored
|
||||
// groups — so an outage costs one attempt per interval, not one per request.
|
||||
if (groupRefreshDue)
|
||||
{
|
||||
var outcome = await _directory!.RefreshAsync(username, ct).ConfigureAwait(false);
|
||||
if (outcome.Decision == DirectoryGroupDecision.EndSession)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Cookie session for {Username} ended: the directory reports {Status}.", username, outcome.Status);
|
||||
return SessionValidationResult.Reject;
|
||||
}
|
||||
|
||||
if (outcome.Decision == DirectoryGroupDecision.UseGroups)
|
||||
{
|
||||
if (!groups.OrderBy(g => g, StringComparer.OrdinalIgnoreCase)
|
||||
.SequenceEqual(outcome.Groups.OrderBy(g => g, StringComparer.OrdinalIgnoreCase), StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"LDAP group membership for {Username} changed mid-session ({Before} -> {After} groups); re-mapping roles.",
|
||||
username, groups.Count, outcome.Groups.Count);
|
||||
}
|
||||
|
||||
groups = outcome.Groups;
|
||||
}
|
||||
|
||||
groupAnchor = now;
|
||||
}
|
||||
|
||||
// Re-run the DB-backed mapping on the (possibly just re-read) groups.
|
||||
var mapping = await _roleMapper.MapAsync(groups, ct).ConfigureAwait(false);
|
||||
var scope = mapping.Scope is RoleMappingResult mapped
|
||||
? mapped
|
||||
@@ -212,7 +257,7 @@ public sealed class CookieSessionValidator
|
||||
|
||||
// Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
|
||||
// anchor (we just refreshed) and the idle anchor (this is a genuine request).
|
||||
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now);
|
||||
var rebuilt = SessionClaimBuilder.Build(username, displayName, groups, scope, now, groupRefreshTimestamp: groupAnchor);
|
||||
var identity = (ClaimsIdentity)rebuilt.Identity!;
|
||||
var sessionId = principal.FindFirst(SessionActivityTracker.SessionIdClaim);
|
||||
identity.RemoveClaim(identity.FindFirst(SessionActivityTracker.SessionIdClaim)!);
|
||||
@@ -225,9 +270,31 @@ public sealed class CookieSessionValidator
|
||||
identity.AddClaim(new Claim(JwtTokenService.LastActivityClaimType, lastActivity));
|
||||
}
|
||||
else _activity?.Record(rebuilt, now);
|
||||
return rebuilt;
|
||||
return SessionValidationResult.Replace(rebuilt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the directory group re-query is active and the session's
|
||||
/// <see cref="JwtTokenService.LastGroupRefreshClaimType"/> anchor is older than
|
||||
/// <see cref="LdapGroupRefreshOptions.IntervalMinutes"/>. A missing/unparsable anchor (a cookie
|
||||
/// issued before ScadaBridge#46) is due. Always false when no <see cref="DirectoryGroupRefresher"/>
|
||||
/// is active (feature off, no lookup registered).
|
||||
/// </summary>
|
||||
/// <param name="principal">The cookie principal.</param>
|
||||
/// <param name="now">The current instant.</param>
|
||||
/// <returns><c>true</c> if the groups should be re-read from the directory now.</returns>
|
||||
public bool IsGroupRefreshDue(ClaimsPrincipal principal, DateTimeOffset now)
|
||||
{
|
||||
if (_directory is not { IsActive: true })
|
||||
return false;
|
||||
|
||||
var anchor = ReadAnchor(principal, JwtTokenService.LastGroupRefreshClaimType);
|
||||
return anchor is null || now - anchor.Value >= _directory.Interval;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? ReadAnchor(ClaimsPrincipal principal, string claimType)
|
||||
=> principal.FindFirst(claimType) is { } claim && DateTimeOffset.TryParse(claim.Value, out var value) ? value : null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the elapsed time since the last role refresh exceeds
|
||||
/// <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>. A missing/unparsable
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
|
||||
/// <summary>What a directory group re-query means for an active session.</summary>
|
||||
public enum DirectoryGroupDecision
|
||||
{
|
||||
/// <summary>The directory answered with the user's current groups; re-map those.</summary>
|
||||
UseGroups,
|
||||
|
||||
/// <summary>No usable answer (directory unavailable, lookup not supported or switched off); keep the stored groups.</summary>
|
||||
KeepStored,
|
||||
|
||||
/// <summary>The directory says this user could no longer sign in (gone, disabled, ambiguous, no groups); end the session.</summary>
|
||||
EndSession,
|
||||
}
|
||||
|
||||
/// <summary>Outcome of <see cref="DirectoryGroupRefresher.RefreshAsync"/>.</summary>
|
||||
/// <param name="Decision">What the session validator must do.</param>
|
||||
/// <param name="Groups">The directory's current groups when <paramref name="Decision"/> is <see cref="DirectoryGroupDecision.UseGroups"/>; otherwise empty.</param>
|
||||
/// <param name="Status">The underlying lookup status, for logging; <c>null</c> when no lookup ran.</param>
|
||||
public sealed record DirectoryGroupOutcome(
|
||||
DirectoryGroupDecision Decision,
|
||||
IReadOnlyList<string> Groups,
|
||||
LdapGroupLookupStatus? Status)
|
||||
{
|
||||
/// <summary>Keep the stored groups; nothing was asked, or nothing usable came back.</summary>
|
||||
public static DirectoryGroupOutcome Keep(LdapGroupLookupStatus? status = null) =>
|
||||
new(DirectoryGroupDecision.KeepStored, Array.Empty<string>(), status);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mid-session LDAP group re-query (ScadaBridge#46). Asks the shared library's passwordless
|
||||
/// <see cref="ILdapGroupLookup"/> (service-account bind) for a user's current groups and turns the
|
||||
/// answer into a <see cref="DirectoryGroupDecision"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Failure policy — availability first.</b> <see cref="LdapGroupLookupStatus.DirectoryUnavailable"/>
|
||||
/// keeps the session's stored groups (logged at Warning): an LDAP outage must not sign engineers out
|
||||
/// or strip their roles mid-work, which matches the "active sessions continue during an LDAP outage"
|
||||
/// requirement. <see cref="LdapGroupLookupStatus.NotSupported"/> / <see cref="LdapGroupLookupStatus.ProviderDisabled"/>
|
||||
/// (no service account, LDAP off) also keep them. Every answer under which a fresh sign-in would now be
|
||||
/// refused for a user-side reason — <see cref="LdapGroupLookupStatus.UserNotFound"/>,
|
||||
/// <see cref="LdapGroupLookupStatus.UserDisabled"/>, <see cref="LdapGroupLookupStatus.AmbiguousUser"/>, or
|
||||
/// <see cref="LdapGroupLookupStatus.Found"/> with zero groups — ends the session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Answers are reused per user for <see cref="LdapGroupRefreshOptions.CacheSeconds"/> on this node, so a
|
||||
/// user's cookie path and every circuit they hold collapse into one directory query. Singleton.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DirectoryGroupRefresher
|
||||
{
|
||||
private readonly ILdapGroupLookup? _lookup;
|
||||
private readonly LdapGroupRefreshOptions _options;
|
||||
private readonly TimeProvider _clock;
|
||||
private readonly ILogger<DirectoryGroupRefresher> _logger;
|
||||
private readonly ConcurrentDictionary<string, (DateTimeOffset At, DirectoryGroupOutcome Outcome)> _cache =
|
||||
new(StringComparer.OrdinalIgnoreCase);
|
||||
private int _notSupportedLogged;
|
||||
|
||||
/// <summary>Initializes the refresher.</summary>
|
||||
/// <param name="options">Security options carrying <see cref="SecurityOptions.LdapGroupRefresh"/>.</param>
|
||||
/// <param name="clock">Clock for the cache window.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="lookup">The shared library's lookup; <c>null</c> (not registered) behaves as not supported.</param>
|
||||
public DirectoryGroupRefresher(
|
||||
IOptions<SecurityOptions> options,
|
||||
TimeProvider clock,
|
||||
ILogger<DirectoryGroupRefresher> logger,
|
||||
ILdapGroupLookup? lookup = null)
|
||||
{
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.LdapGroupRefresh ?? new();
|
||||
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_lookup = lookup;
|
||||
}
|
||||
|
||||
/// <summary>True when the feature is switched on and a lookup is available to ask.</summary>
|
||||
public bool IsActive => _options.Enabled && _lookup is not null;
|
||||
|
||||
/// <summary>The configured re-query interval.</summary>
|
||||
public TimeSpan Interval => TimeSpan.FromMinutes(_options.IntervalMinutes);
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads <paramref name="username"/>'s groups from the directory. Never throws except for
|
||||
/// cancellation; an unexpected lookup fault is treated as an unavailable directory.
|
||||
/// </summary>
|
||||
/// <param name="username">The session's canonical username.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The decision for the session.</returns>
|
||||
public async Task<DirectoryGroupOutcome> RefreshAsync(string username, CancellationToken ct)
|
||||
{
|
||||
if (!IsActive || string.IsNullOrWhiteSpace(username))
|
||||
return DirectoryGroupOutcome.Keep();
|
||||
|
||||
var now = _clock.GetUtcNow();
|
||||
if (_options.CacheSeconds > 0
|
||||
&& _cache.TryGetValue(username, out var hit)
|
||||
&& now - hit.At < TimeSpan.FromSeconds(_options.CacheSeconds))
|
||||
{
|
||||
return hit.Outcome;
|
||||
}
|
||||
|
||||
LdapGroupLookupResult result;
|
||||
try
|
||||
{
|
||||
result = await _lookup!.GetUserGroupsAsync(username, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (ct.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "LDAP group re-query for {Username} faulted; keeping the session's stored groups.", username);
|
||||
result = LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable);
|
||||
}
|
||||
|
||||
var outcome = Decide(username, result);
|
||||
if (_options.CacheSeconds > 0)
|
||||
_cache[username] = (now, outcome);
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/// <summary>Drops every cached answer (tests; an operator-driven reset).</summary>
|
||||
public void Clear() => _cache.Clear();
|
||||
|
||||
private DirectoryGroupOutcome Decide(string username, LdapGroupLookupResult result)
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case LdapGroupLookupStatus.Found when result.Groups.Count > 0:
|
||||
return new DirectoryGroupOutcome(DirectoryGroupDecision.UseGroups, result.Groups, result.Status);
|
||||
|
||||
case LdapGroupLookupStatus.Found:
|
||||
case LdapGroupLookupStatus.UserNotFound:
|
||||
case LdapGroupLookupStatus.UserDisabled:
|
||||
case LdapGroupLookupStatus.AmbiguousUser:
|
||||
_logger.LogWarning(
|
||||
"LDAP group re-query for {Username} answered {Status}{Detail}; ending the session (a new sign-in would be refused).",
|
||||
username, result.Status, result.Status == LdapGroupLookupStatus.Found ? " with no groups" : "");
|
||||
return new DirectoryGroupOutcome(DirectoryGroupDecision.EndSession, Array.Empty<string>(), result.Status);
|
||||
|
||||
case LdapGroupLookupStatus.DirectoryUnavailable:
|
||||
_logger.LogWarning(
|
||||
"LDAP group re-query for {Username}: directory unavailable on every endpoint; keeping the session's stored groups.",
|
||||
username);
|
||||
return DirectoryGroupOutcome.Keep(result.Status);
|
||||
|
||||
default: // NotSupported, ProviderDisabled
|
||||
if (Interlocked.Exchange(ref _notSupportedLogged, 1) == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"LDAP group re-query is enabled but the directory configuration cannot search without the user's " +
|
||||
"password ({Status}); sessions keep their sign-in groups until next login.", result.Status);
|
||||
}
|
||||
|
||||
return DirectoryGroupOutcome.Keep(result.Status);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,12 @@ public class JwtTokenService
|
||||
public const string GroupClaimType = "zb:group";
|
||||
public const string LastRoleRefreshClaimType = "zb:lastrolerefresh";
|
||||
|
||||
/// <summary>
|
||||
/// Anchor of the last time the session's <see cref="GroupClaimType"/> claims were read from the
|
||||
/// directory (login, or the mid-session LDAP group re-query, ScadaBridge#46). ISO-8601 "o".
|
||||
/// </summary>
|
||||
public const string LastGroupRefreshClaimType = "zb:lastgrouprefresh";
|
||||
|
||||
/// <summary>
|
||||
/// Fixed issuer bound into every token and required on validation. Binding
|
||||
/// issuer/audience is defence-in-depth: even though the HMAC key is shared only
|
||||
|
||||
@@ -80,11 +80,10 @@ public class SecurityOptions
|
||||
/// <b>15 minutes</b>, matching the documented sliding-refresh cadence.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is a purely central (database) refresh — it picks up LDAP-group→role mapping
|
||||
/// changes and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
|
||||
/// within this window. It does NOT pick up live LDAP group-membership changes (the
|
||||
/// shared LDAP library exposes no passwordless group-search; that remains a
|
||||
/// next-login refresh — see Component-Security.md).
|
||||
/// This is the central (database) refresh — it picks up LDAP-group→role mapping changes
|
||||
/// and scope-rule changes, so revoked roles take effect within this window. Live LDAP
|
||||
/// group-membership changes are re-read on their own cadence, <see cref="LdapGroupRefresh"/>
|
||||
/// (ScadaBridge#46; see Component-Security.md).
|
||||
/// <para>
|
||||
/// Because a role-refresh is also the only operation that advances the
|
||||
/// <c>LastActivity</c> anchor, the effective maximum idle window is approximately
|
||||
@@ -95,6 +94,12 @@ public class SecurityOptions
|
||||
/// </remarks>
|
||||
public int RoleRefreshThresholdMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Mid-session re-query of the user's LDAP group membership (ScadaBridge#46), bound from
|
||||
/// <c>ScadaBridge:Security:LdapGroupRefresh</c>.
|
||||
/// </summary>
|
||||
public LdapGroupRefreshOptions LdapGroupRefresh { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When true (default) the authentication cookie is always marked
|
||||
/// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
|
||||
@@ -161,6 +166,33 @@ public class SecurityOptions
|
||||
/// <see cref="ServiceCollectionExtensions.AddSecurity"/> so a misconfigured appsettings
|
||||
/// section is caught at application startup rather than silently misapplied at runtime.
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Settings for the mid-session LDAP group re-query (ScadaBridge#46). The re-query uses the shared
|
||||
/// library's passwordless <c>ILdapGroupLookup</c>, which binds as the configured LDAP service account.
|
||||
/// </summary>
|
||||
public sealed class LdapGroupRefreshOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// On by default. It is effectively inactive — stored groups are kept, nothing is queried — when
|
||||
/// LDAP is configured without a service account (<c>BindMode = User</c>) or disabled, and under
|
||||
/// <c>DisableLogin</c> (no cookie validation runs at all).
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// How often an active session's groups are re-read from the directory, in minutes (default 15,
|
||||
/// the same cadence as <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>). Measured from the
|
||||
/// session's <c>zb:lastgrouprefresh</c> anchor, which login stamps. Must be at least 1.
|
||||
/// </summary>
|
||||
public int IntervalMinutes { get; set; } = 15;
|
||||
|
||||
/// <summary>
|
||||
/// Per-node reuse window for one user's lookup answer, in seconds (default 60; 0 disables). It
|
||||
/// collapses the cookie path and every open circuit of the same user into one directory query.
|
||||
/// </summary>
|
||||
public int CacheSeconds { get; set; } = 60;
|
||||
}
|
||||
|
||||
public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions<SecurityOptions>
|
||||
{
|
||||
/// <summary>Validates the bound <see cref="SecurityOptions"/> at application startup, failing fast on invalid combinations.</summary>
|
||||
@@ -241,6 +273,20 @@ public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IVal
|
||||
$"({options.MaxLoginFailuresPerWindow}).");
|
||||
}
|
||||
|
||||
if (options.LdapGroupRefresh is { Enabled: true, IntervalMinutes: < 1 })
|
||||
{
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
||||
$"{nameof(SecurityOptions.LdapGroupRefresh)}:{nameof(LdapGroupRefreshOptions.IntervalMinutes)} " +
|
||||
$"({options.LdapGroupRefresh.IntervalMinutes}) must be at least 1 when the refresh is enabled.");
|
||||
}
|
||||
|
||||
if (options.LdapGroupRefresh is { CacheSeconds: < 0 })
|
||||
{
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
|
||||
$"{nameof(SecurityOptions.LdapGroupRefresh)}:{nameof(LdapGroupRefreshOptions.CacheSeconds)} " +
|
||||
$"({options.LdapGroupRefresh.CacheSeconds}) must be non-negative (0 disables the cache).");
|
||||
}
|
||||
|
||||
return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,10 @@ public static class ServiceCollectionExtensions
|
||||
// is the wall clock. TryAddSingleton keeps the Host free to register its own.
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
services.AddScoped<CookieSessionValidator>();
|
||||
// ScadaBridge#46: mid-session LDAP group re-query. Singleton (its per-user answer cache is
|
||||
// node-wide); it takes the shared library's ILdapGroupLookup, which AddZbLdapAuth (Auth 0.5.0)
|
||||
// registers at the Host composition root. Absent that registration it is inert.
|
||||
services.AddSingleton<DirectoryGroupRefresher>();
|
||||
services.AddSingleton<SessionActivityTracker>();
|
||||
|
||||
// Audit Actor wiring (Phase 3): the user-facing inbound API audit path
|
||||
@@ -163,8 +167,9 @@ public static class ServiceCollectionExtensions
|
||||
|
||||
// OnValidatePrincipal enforces the idle timeout and refreshes
|
||||
// the role/scope claims from the session's STORED LDAP groups (DB-backed
|
||||
// RoleMapper, NO LDAP) so central role-mapping changes take effect
|
||||
// mid-session. The lambda is a THIN adapter: it resolves the request-scoped
|
||||
// RoleMapper; the groups themselves are re-read from the directory every
|
||||
// LdapGroupRefresh:IntervalMinutes, ScadaBridge#46) so central role-mapping and
|
||||
// directory group changes take effect mid-session. The lambda is a THIN adapter: it resolves the request-scoped
|
||||
// CookieSessionValidator (which holds all the testable idle/refresh logic)
|
||||
// and translates its decision into the cookie context calls. It NEVER
|
||||
// throws — CookieSessionValidator.ValidateAsync swallows refresh faults and
|
||||
@@ -249,7 +254,7 @@ public static class ServiceCollectionExtensions
|
||||
/// adapter. It resolves the request-scoped <see cref="CookieSessionValidator"/>,
|
||||
/// asks it for a decision, and applies it to the cookie context:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle-timeout — the only sign-out path).</item>
|
||||
/// <item><see cref="SessionValidationAction.Reject"/> → <see cref="CookieValidatePrincipalContext.RejectPrincipal"/> + sign out (idle timeout, or the directory reports the user gone/disabled/ambiguous/group-less — ScadaBridge#46).</item>
|
||||
/// <item><see cref="SessionValidationAction.Replace"/> → <see cref="CookieValidatePrincipalContext.ReplacePrincipal"/> + renewal only for non-passive requests (role mapping refreshed).</item>
|
||||
/// <item><see cref="SessionValidationAction.Keep"/> → no-op (no refresh due, or a swallowed refresh fault).</item>
|
||||
/// </list>
|
||||
@@ -286,8 +291,9 @@ public static class ServiceCollectionExtensions
|
||||
switch (result.Action)
|
||||
{
|
||||
case SessionValidationAction.Reject:
|
||||
// Idle-timeout: drop the principal AND clear the cookie so the next
|
||||
// request is treated as anonymous and redirected to /login.
|
||||
// Idle timeout, or the directory ended the session (ScadaBridge#46): drop the
|
||||
// principal AND clear the cookie so the next request is treated as anonymous
|
||||
// and redirected to /login.
|
||||
context.RejectPrincipal();
|
||||
await context.HttpContext
|
||||
.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
|
||||
/// — the durable input the mid-session RoleMapper re-run consumes.</item>
|
||||
/// <item><see cref="JwtTokenService.LastRoleRefreshClaimType"/> — the role-mapping
|
||||
/// refresh anchor, ISO-8601 round-trippable.</item>
|
||||
/// <item><see cref="JwtTokenService.LastGroupRefreshClaimType"/> — when the stored groups
|
||||
/// were last read from the directory (login, or the mid-session re-query).</item>
|
||||
/// <item><see cref="JwtTokenService.LastActivityClaimType"/> — the idle-timeout
|
||||
/// anchor; seeded to the refresh timestamp at login so idle-timeout can be
|
||||
/// enforced consistently from the very first request.</item>
|
||||
@@ -47,6 +49,11 @@ public static class SessionClaimBuilder
|
||||
/// <param name="mapping">The DB-backed role mapping (roles + permitted sites + system-wide flag).</param>
|
||||
/// <param name="refreshTimestamp">The role-mapping refresh anchor; also seeds the last-activity anchor.</param>
|
||||
/// <param name="authenticationType">The authentication type stamped on the identity (defaults to the cookie scheme).</param>
|
||||
/// <param name="groupRefreshTimestamp">
|
||||
/// When <paramref name="groups"/> were read from the directory; defaults to
|
||||
/// <paramref name="refreshTimestamp"/> (login reads them at that instant). A refresh that re-maps
|
||||
/// STORED groups passes the session's existing anchor so the directory re-query cadence is kept.
|
||||
/// </param>
|
||||
/// <returns>A fully populated cookie <see cref="ClaimsPrincipal"/>.</returns>
|
||||
public static ClaimsPrincipal Build(
|
||||
string username,
|
||||
@@ -54,7 +61,8 @@ public static class SessionClaimBuilder
|
||||
IReadOnlyList<string> groups,
|
||||
RoleMappingResult mapping,
|
||||
DateTimeOffset refreshTimestamp,
|
||||
string authenticationType = CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
string authenticationType = CookieAuthenticationDefaults.AuthenticationScheme,
|
||||
DateTimeOffset? groupRefreshTimestamp = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(username);
|
||||
ArgumentNullException.ThrowIfNull(displayName);
|
||||
@@ -76,6 +84,7 @@ public static class SessionClaimBuilder
|
||||
// advances it. LastRoleRefresh advances when the mapping is re-run.
|
||||
new(JwtTokenService.LastRoleRefreshClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastActivityClaimType, refreshStamp),
|
||||
new(JwtTokenService.LastGroupRefreshClaimType, (groupRefreshTimestamp ?? refreshTimestamp).ToString("o")),
|
||||
};
|
||||
|
||||
foreach (var role in mapping.Roles)
|
||||
|
||||
Reference in New Issue
Block a user