diff --git a/Directory.Packages.props b/Directory.Packages.props
index 3c490192..a695772c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -111,11 +111,11 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/docs/components/Security.md b/docs/components/Security.md
index d9213d54..e2591a22 100644
--- a/docs/components/Security.md
+++ b/docs/components/Security.md
@@ -21,11 +21,11 @@ The component registers:
Authentication uses a direct username/password bind against the LDAP/AD server via the shared `ILdapAuthService` (`ZB.MOM.WW.Auth.Ldap`). The flow is: service-account bind → search for the user entry by username → user-credential bind → group-membership query. The app never caches credentials locally. LDAPS (port 636) or StartTLS is required for production; the `AllowInsecure` flag in `LdapOptions` gates unencrypted use to explicitly opted-in deployments (local dev only). No Kerberos/NTLM path exists.
-LDAP failure behavior is fail-closed at the login boundary and fail-open at the session boundary: a new login fails immediately if the directory is unreachable; an active cookie session continues with its current claims (its mid-session role refresh is DB-only and never calls LDAP), and a CLI holding a bearer token keeps working until the token expires. This avoids disrupting engineers mid-work during a brief directory outage. Group-membership changes are picked up at the next login.
+LDAP failure behavior is fail-closed at the login boundary and fail-open at the session boundary: a new login fails immediately if the directory is unreachable; an active cookie session continues with its current claims (its periodic directory group re-query keeps the stored groups when the directory cannot answer), and a CLI holding a bearer token keeps working until the token expires. This avoids disrupting engineers mid-work during a brief directory outage. Group-membership changes reach an active session within `LdapGroupRefresh:IntervalMinutes` (default 15, ScadaBridge#46); a deleted or disabled account ends the session.
### Sessions: cookie (UI) and bearer JWT (CLI/API)
-**Interactive UI.** `POST /auth/login` signs the user in with ASP.NET Core cookie authentication. The HttpOnly/Secure cookie carries a data-protected claims ticket (no embedded JWT) built by the shared `SessionClaimBuilder`: display name, username, roles, permitted site IDs (site-scoped Deployer), the raw LDAP groups and the idle / role-refresh anchors. The cookie is the transport because Blazor Server's SignalR circuits do not carry `Authorization` headers on reconnect. Both central nodes share the Data Protection key ring, so either node validates a cookie the other issued. The cookie's `ExpireTimeSpan` is `IdleTimeoutMinutes` (default 30) with sliding expiration; passive traffic does not renew it. Once the role-refresh anchor is older than `RoleRefreshThresholdMinutes` (default 15) the validation hook re-runs the DB-backed `RoleMapper` on the stored groups (no LDAP call).
+**Interactive UI.** `POST /auth/login` signs the user in with ASP.NET Core cookie authentication. The HttpOnly/Secure cookie carries a data-protected claims ticket (no embedded JWT) built by the shared `SessionClaimBuilder`: display name, username, roles, permitted site IDs (site-scoped Deployer), the raw LDAP groups and the idle / role-refresh anchors. The cookie is the transport because Blazor Server's SignalR circuits do not carry `Authorization` headers on reconnect. Both central nodes share the Data Protection key ring, so either node validates a cookie the other issued. The cookie's `ExpireTimeSpan` is `IdleTimeoutMinutes` (default 30) with sliding expiration; passive traffic does not renew it. Once the role-refresh anchor is older than `RoleRefreshThresholdMinutes` (default 15) the validation hook re-runs the DB-backed `RoleMapper` on the stored groups. Separately, once the `zb:lastgrouprefresh` anchor is older than `LdapGroupRefresh:IntervalMinutes` (default 15), the groups themselves are re-read from the directory with the LDAP service account (`DirectoryGroupRefresher` over Auth `ILdapGroupLookup`, ScadaBridge#46) before the re-map.
**CLI / API.** `POST /auth/token` performs one LDAP bind per invocation and returns a short-lived HS256 JWT (`JwtExpiryMinutes`, default 15) signed with `JwtSigningKey`, shared by both central nodes; the response advertises `management_bearer: true`. `ManagementAuthenticator` accepts `Authorization: Bearer` alongside HTTP Basic on `POST /management`, the audit REST endpoints and the debug-stream hub. Roles inside a valid token are authoritative until it expires, so a revocation reaches a bearer caller within `JwtExpiryMinutes`; rotating `JwtSigningKey` invalidates every token. Under `DisableLogin`, `/auth/token` answers 404. Full spec: `docs/requirements/Component-Security.md`.
@@ -138,7 +138,7 @@ Login flow (Central UI `/auth/login` and `/auth/token`):
2. On success, call `RoleMapper.MapGroupsToRolesAsync(ldapGroups)` to resolve roles and site scope.
3. `/auth/login`: build the claims via `SessionClaimBuilder` and call the ASP.NET cookie `SignInAsync`. `/auth/token`: call `JwtTokenService.GenerateToken(displayName, username, roles, permittedSiteIds)` and return the bearer JWT.
-On each subsequent cookie request the validation hook enforces the idle timeout and, past the role-refresh threshold, re-maps the stored groups against the DB. Bearer requests validate the token with `JwtTokenService.ValidateToken` and perform no LDAP bind or role re-map.
+On each subsequent cookie request the validation hook enforces the idle timeout and, past the role-refresh threshold, re-maps the stored groups against the DB (re-reading them from the directory first when the group re-query is due). Bearer requests validate the token with `JwtTokenService.ValidateToken` and perform no LDAP bind or role re-map.
Authorization gates use the named policies:
@@ -162,7 +162,10 @@ if (!AuthorizationPolicies.OperationalAuditRoles.Contains(userRole))
| `JwtSigningKey` | *(required)* | Symmetric HMAC-SHA256 signing key. Must be at least 32 bytes (256 bits); `StartupValidator` rejects a shorter key on Central. |
| `JwtExpiryMinutes` | `15` | Lifetime of the bearer JWT issued by `/auth/token`. |
| `IdleTimeoutMinutes` | `30` | Cookie session idle timeout; set as the cookie `ExpireTimeSpan`. |
-| `RoleRefreshThresholdMinutes` | `15` | Age of the role-refresh anchor after which the cookie's roles are re-mapped from the DB (LDAP-free). |
+| `RoleRefreshThresholdMinutes` | `15` | Age of the role-refresh anchor after which the cookie's roles are re-mapped from the DB. |
+| `LdapGroupRefresh:Enabled` | `true` | Mid-session re-query of the user's directory groups (ScadaBridge#46). Inert when LDAP has no service account (`BindMode = User`) or under `DisableLogin`. |
+| `LdapGroupRefresh:IntervalMinutes` | `15` | Age of the `zb:lastgrouprefresh` anchor after which the groups are re-read from the directory (≥ 1). |
+| `LdapGroupRefresh:CacheSeconds` | `60` | Per-node reuse of one user's directory answer (0 disables). |
| `RoleMappingCacheSeconds` | `30` | Process-wide `RoleMappingCache` TTL (`0` disables); invalidated after mapping/scope-rule edits on the local node and, over the central cache-invalidation topic, on the peer central node (#100). |
| `RequireHttpsCookie` | `true` | When `true`, the cookie is `Secure`-only (HTTPS required). Set `false` for HTTP-only dev deployments; a warning is logged at startup. |
| `CookieName` | `ZB.MOM.WW.ScadaBridge.Auth` | Authentication cookie name. Override per deployment when two ScadaBridge stacks share a hostname — browsers scope cookies by host+path but not by port. |
diff --git a/docs/plans/2026-07-08-deferred-work-register.md b/docs/plans/2026-07-08-deferred-work-register.md
index 75368115..e6b845ae 100644
--- a/docs/plans/2026-07-08-deferred-work-register.md
+++ b/docs/plans/2026-07-08-deferred-work-register.md
@@ -23,7 +23,7 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 11 | **→ #43** Central-persisted OPC UA cert-trust audit | m7 follow-ups | Broadcast-to-both-nodes covers HA | Governance/audit requirement for trust decisions |
| 17 | **→ #44** Unified notifications+site-calls outbox page | stillpending :118 | Explicit M9 decision to keep two pages | Operator confusion reports |
| 19 | **→ #45** Bundle signing / cluster-to-cluster pull / differential bundles | transport-design :402 | v1 manifest hash + AES-GCM held sufficient | Non-repudiation requirement across orgs |
-| 23 | **→ #46** Live LDAP group-membership re-query for an active session | `docs/requirements/Component-Security.md` :61-69 (+ :78-79) | Blocked on an external package. The mid-session refresh re-maps the **stored** groups against the central DB with **no LDAP call**, so a directory group-membership change lands only at next login. A live re-query needs a passwordless service-account group-search method on the shared `ZB.MOM.WW.Auth.Ldap` library — an external NuGet `PackageReference` (`src/ZB.MOM.WW.ScadaBridge.Security/…csproj:23`) exposing only `AuthenticateAsync(username, password, ct)`. Central role-mapping/scope changes still apply within ~15 min (`RoleRefreshThresholdMinutes`). | `ZB.MOM.WW.Auth.Ldap` gains a standalone group-search API, or a requirement that a directory-side group revocation take effect mid-session rather than at next login |
+| 23 | **→ #46 — DONE 2026-11-12** Live LDAP group-membership re-query for an active session | `docs/requirements/Component-Security.md` *Directory group re-query* | Resolved: Auth `0.5.0` added the passwordless `ILdapGroupLookup` (service account); ScadaBridge re-reads an active session's groups every `LdapGroupRefresh:IntervalMinutes` (default 15) via `DirectoryGroupRefresher` — directory unavailable keeps stored groups, user not found/disabled/ambiguous/no groups ends the session. Residual: `BindMode = User` deployments cannot re-query (next-login behaviour). | — (closed) |
| 24 | **→ #47** M8 large-bundle performance hardening | `docs/plans/2026-06-15-stillpending-completion-design.md:106` — "Small follow-ups logged (not blocking): … large-bundle/perf hardening" | Logged as a non-blocking follow-up when M8 shipped and never given an artifact: **no plan, no task entry, no perf/load test exists** (`tests/…Transport.Tests/Import/BundleImporterLoadTests.cs` is a `LoadAsync` unit suite despite the name). No measured problem; the only sizing controls in place are the 5-minute CLI transport timeout, `LineDiffer`'s `MaxInputLines`=4000 summary-only cap, and `MaxConcurrentImportSessions`=8. | First real bundle that times out, exhausts memory, or makes the import wizard's diff step unusable |
| 25 | ~~**Phase-8 WP-4 target-scale load test** (10 sites x 500 instances x 75 tags = 37,500 subscriptions/site, 375,000 total)~~ **RESOLVED 2026-08-15 — run, measured, evidenced.** The 107-byte stub is replaced by a real full-scale run: **10 x 500 x 75 = 375,000 live tag subscriptions**, 37,518 tag updates/s achieved against 37,500 nominal (100.0%), **45,021,375 updates offered** over a 20-minute steady-state window on an M4 Pro / 14-core / 48 GB host with the 8-node `docker/` rig still running. Headline numbers: tag update latency **P50 0.88 ms / P99 4.57 ms / max 37.41 ms** end-to-end (DCL boundary to stream subscriber, the emit instant carried verbatim through production code); **0 events dropped** at 100 live subscribers; health report collect+ingest **P99 0.31 ms** with 10/10 sites tracked; debug view snapshot under load **P99 2.19 ms, 0 timeouts**; 500 instances deployed to a site in **2.6 s**; CPU **2.9% of the box**; working-set slope **+8.83 MB/min**. **11 clean passes, 1 pass with a caveat (F1: 20 min with zero gen-2 collections cannot fully settle the leak question — the 1-hour run would), 0 failures.** Harness: `tests/ZB.MOM.WW.ScadaBridge.LoadHarness/` (standalone Exe — the `Category=Performance` trait enables a filter but does NOT exclude by default, so a 20-minute test could not live in `PerformanceTests`) plus a CI-scale smoke `[Fact]` (`PerformanceTests/TargetScale/TargetScaleHarnessSmokeTests.cs`). Real: per-site ActorSystem + LocalDb, the real DCL via the documented `DataConnectionFactory.RegisterAdapter` seam, real `InstanceActor`s, real `SiteStreamManager`, real `StreamRelayActor` + production-capacity bounded channel, real S&F, real health collector/aggregator; only the socket hops are stood in for. **Results: `docs/plans/2026-08-15-target-scale-load-test-results.md`; design + deviations + thresholds: `docs/plans/2026-08-15-target-scale-load-test-design.md`.** | as before | Closed. Residual scope: `[xc-3]` site-event-log retention volume, `[xc-4]` audit-vs-central degradation, `[xc-5]` large-template flattening and `[xc-8]` UI responsiveness are NOT covered by this harness (central-cluster/browser concerns) and remain open WP-4 scope — see results doc SS6. | Closed 2026-08-15. |
| 26 | **→ #48** Ipsen MES MoveIn tail: leak-test (`-LT`) receivers + routing, PLC-output-flag writes, `Z28062` BTDB data completeness | `docs/plans/2026-06-16-ipsen-mes-movein.md:409` ("Out of scope (future)"); design `2026-06-16-ipsen-mes-movein-design.md:58-60, :196-198` | Customer-site scope, not a platform gap. `-LT` routing needs an MES-receiver child + Galaxy reference that do not exist on the reactor template (any `-LT`/unknown suffix returns `WasSuccessful=false` with an "unsupported side/target" message by decision); `MoveInComplete`/`Successful`/`ErrorText` are **PLC-owned** by locked decision, so ScadaBridge deliberately does not write them; `Z28062` completeness is an operational data fix, not code. Note the separate alarm-status path already handles the suffix — `_LT` is stripped before side-scoping (`2026-06-30-mes-alarm-status-api.md:158`). | Ipsen creates the leak-test receiver + Galaxy reference, or asks ScadaBridge to own the PLC-output flags — otherwise a **candidate won't-do** (`[PERM]`) at the next Ipsen scope review |
diff --git a/docs/requirements/Component-Host.md b/docs/requirements/Component-Host.md
index be8cb9ad..91ca9a22 100644
--- a/docs/requirements/Component-Host.md
+++ b/docs/requirements/Component-Host.md
@@ -58,7 +58,7 @@ The Host must bind configuration sections from `appsettings.json` to strongly-ty
| `ScadaBridge:HealthMonitoring` | `HealthMonitoringOptions` | Health Monitoring | ReportInterval, OfflineTimeout |
| `ScadaBridge:SiteEventLog` | `SiteEventLogOptions` | Site Event Logging | RetentionDays, MaxStorageMb, PurgeScheduleCron |
| `ScadaBridge:Communication` | `CommunicationOptions` | Communication | DeploymentTimeout, LifecycleTimeout, QueryTimeout, TransportHeartbeatInterval, TransportFailureThreshold |
-| `ScadaBridge:Security` | `SecurityOptions` | Security & Auth | LdapServer, LdapPort, LdapUseTls, JwtSigningKey (HS256 key for the CLI/API bearer token from `/auth/token`; >= 32 bytes, validated on Central), JwtExpiryMinutes (bearer lifetime), IdleTimeoutMinutes (cookie idle window), RoleRefreshThresholdMinutes, RoleMappingCacheSeconds |
+| `ScadaBridge:Security` | `SecurityOptions` | Security & Auth | LdapServer, LdapPort, LdapUseTls, JwtSigningKey (HS256 key for the CLI/API bearer token from `/auth/token`; >= 32 bytes, validated on Central), JwtExpiryMinutes (bearer lifetime), IdleTimeoutMinutes (cookie idle window), RoleRefreshThresholdMinutes, RoleMappingCacheSeconds, LdapGroupRefresh (`Enabled`/`IntervalMinutes`/`CacheSeconds` — mid-session directory group re-query, ScadaBridge#46) |
| `ScadaBridge:InboundApi` | `InboundApiOptions` | Inbound API | DefaultMethodTimeout, MaxRequestBodyBytes, ApiMethodCacheTtl, StandbyForwardPeerEndpoints (HTTP base URLs of the other central node — a standby relays `POST /api/*` there instead of answering 503), StandbyForwardTimeout |
| `ScadaBridge:Notification` | `NotificationOptions` | Notification Service | (SMTP config is stored in the central config DB, not in appsettings) |
| `ScadaBridge:NotificationOutbox` | `NotificationOutboxOptions` | Notification Outbox | Dispatcher poll interval, stuck-age threshold, retention window (delivery retry settings reuse the central SMTP configuration) |
diff --git a/docs/requirements/Component-Security.md b/docs/requirements/Component-Security.md
index e3fedc71..e7623150 100644
--- a/docs/requirements/Component-Security.md
+++ b/docs/requirements/Component-Security.md
@@ -52,22 +52,25 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
### Cookie session lifecycle
- **Idle timeout**: Configurable, default **30 minutes**. Without eligible activity within the idle window, the session is rejected and the user must re-login. Passive `/auth/ping`, activity prevalidation, and Blazor transport requests preserve the activity anchor and suppress sliding renewal; the activity endpoint renews only after authentication and antiforgery validation. Tracked via a `LastActivity` last-activity timestamp claim. The cookie's `ExpireTimeSpan` is set to the idle timeout and `SlidingExpiration` renews it on activity, so the cookie window and the explicit `OnValidatePrincipal` idle check use the **same** value and cannot contradict each other.
-- **Role-mapping refresh (LDAP-free)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the stored groups — **with no LDAP call** — rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch.
+- **Role-mapping refresh (DB)**: Configurable, default **15 minutes** (`SecurityOptions.RoleRefreshThresholdMinutes`). At login the session stores the user's raw LDAP groups (one `zb:group` claim each) plus a `zb:lastrolerefresh` anchor. Once the anchor is older than the threshold, `OnValidatePrincipal` re-runs the **DB-backed** `RoleMapper` on the session's groups (the stored ones, or the ones just re-read from the directory — see the next item), rebuilds the role/scope claims via the shared claim-builder, advances the anchor, and re-issues the cookie. Central role-mapping (DB) changes — including a **revoked** mapping that drops the user's roles, and changed site-scope rules — take effect within this window. Ordinary HTTP validation uses this threshold. A live circuit refreshes mappings in fresh scopes on authentication-state reads — display reads (`GetAuthenticationStateAsync`) at most once per `CookieAuthenticationStateProvider.NonAuthoritativeRefreshInterval` (10 s) per circuit — and mutation entrypoints (`GetAuthoritativeStateAsync`) require a successful current mapping before every dispatch.
+- **Directory group re-query (ScadaBridge#46)**: Configurable, default **on, every 15 minutes** (`SecurityOptions.LdapGroupRefresh`: `Enabled` = `true`, `IntervalMinutes` = `15`, `CacheSeconds` = `60`; config `ScadaBridge:Security:LdapGroupRefresh:*`). Login also stamps a `zb:lastgrouprefresh` anchor. Once it is older than `IntervalMinutes`, the next validation — HTTP `OnValidatePrincipal`, a circuit display read or an authoritative mutation check — re-reads the user's groups from the directory through the shared library's passwordless `ILdapGroupLookup` (Auth `0.5.0`; it binds as the configured **service account**, never the user), replaces the stored `zb:group` claims, re-runs the role mapping, and advances the anchor. A directory group removal or addition therefore reaches an open session — browser and circuit — within the interval, without re-login. Its cadence is independent of `RoleRefreshThresholdMinutes` and of the 10 s circuit refresh: a circuit queries the directory at most once per interval. One user's answer is reused on a node for `CacheSeconds`, so the cookie path and every circuit of that user cost one directory query. A cookie issued before this feature (no anchor) is due on its first validation.
+ - **Outcome policy.** *Directory unavailable* (connect/TLS, service-account bind or search failed on every endpoint, including `FallbackServers`) **keeps the stored groups and roles** and logs a warning — on every path, including the fail-closed authoritative check — and the anchor still advances, so an outage costs one attempt per interval rather than one per request (availability first, consistent with *LDAP Connection Failure* below). Any answer under which a **new login would now be refused for a user-side reason** — account **not found** (deleted/renamed), **disabled** (AD `userAccountControl`), **ambiguous** (two entries), or found with **no groups** — **ends the session** on every path: HTTP rejects the principal and clears the cookie (the user lands on `/login`), and a circuit's state becomes anonymous so the next gate denies. Ending the session (rather than leaving a signed-in, role-less session) is chosen because it is what the login path would decide and it cannot be mistaken for a working account. *Not supported* (LDAP configured with `BindMode = User`, i.e. no service account) or LDAP disabled keeps the stored groups; the feature is then inert and logs that once.
+ - **Off switches.** `LdapGroupRefresh:Enabled = false` restores the pre-#46 behaviour (stored groups until next login). Under `ScadaBridge:Security:Auth:DisableLogin` no cookie validation runs at all, so no directory query is made. Bearer tokens and HTTP Basic are unaffected: a token's claims are authoritative for its lifetime, and Basic binds per request.
- **Role-mapping cache**: `RoleMapper` reads the LDAP-group→role table and the scope rules of every Deployer mapping through a process-wide `RoleMappingCache` snapshot (`SecurityOptions.RoleMappingCacheSeconds`, default **30**; `0` disables). One refresh loads the whole table plus all Deployer scope rules, so a cache hit costs no database round-trip — previously every Basic-authenticated management request and every circuit auth-state read issued `GetAllMappingsAsync` plus one `GetScopeRulesForMappingAsync` per matched Deployer mapping. Without an explicit invalidation a mapping or scope-rule change is therefore visible on every node within the TTL. `RoleMappingCache.Invalidate()` discards the calling node's snapshot so its next mapping re-reads the database (a refresh started before an invalidation is never published over it); the Management Service's mapping/scope-rule handlers also publish `RoleMappingsInvalidated` on the central cache-invalidation topic, so the peer central node discards its snapshot too (audit D1, ScadaBridge#100 item 3) and the TTL is only the fallback. `RoleMappingCacheSeconds = 0` bypasses the snapshot and reads only the matched mappings' scope rules per call.
#### Session Validation (`OnValidatePrincipal`)
- The cookie principal is built at login by a **single shared claim-builder** (`SessionClaimBuilder`). The `OnValidatePrincipal` role-refresh path rebuilds the principal through the **same** builder, so the login and refresh claim shapes cannot drift.
- **Failure policy**: ordinary cookie validation and display-state refresh retain existing grants on a mapping lookup error. Authoritative circuit mutation checks fail closed on the same error, so a mutation cannot proceed using grants that could not be refreshed. Idle timeout rejects the principal.
-> **Residual limitation — live LDAP group-membership changes (follow-up).** The
-> mid-session refresh re-maps the **stored** groups against the central database; it does
-> **not** re-query LDAP, so a change to the user's actual **group membership** in the
-> directory is picked up only at **next login**. A live group re-query for an active
-> session would require a new passwordless service-account group-search method on the
-> shared `ZB.MOM.WW.Auth.Ldap` library, which is an **external NuGet package** and exposes
-> only `AuthenticateAsync(username, password, ct)` (no standalone group search). Adding
-> that method is tracked as a follow-up. Until then: central role-mapping/scope changes are
-> reflected during HTTP refresh and immediately at circuit mutation checks; directory group-membership changes require re-login.
+> **Directory group-membership changes (resolved by ScadaBridge#46).** Earlier builds re-mapped
+> only the **stored** groups mid-session, so a directory group change waited for the next login;
+> the shared `ZB.MOM.WW.Auth.Ldap` library had no passwordless group search. Auth `0.5.0` added
+> `ILdapGroupLookup`, and the *Directory group re-query* above now re-reads an active session's
+> groups every `LdapGroupRefresh:IntervalMinutes` (default 15). Remaining bounds: a change lands
+> within that interval (plus up to `CacheSeconds` of per-node answer reuse), not instantly; a
+> bearer token keeps its claims until it expires (≤ `JwtExpiryMinutes`); and a deployment that
+> authenticates with `BindMode = User` (no service account) cannot re-query, so it keeps the
+> next-login behaviour.
### Load Balancer Compatibility
- Neither the cookie nor the bearer token needs server-side session state. A load balancer in front of the central cluster can route requests to either node without sticky sessions or a shared session store.
@@ -76,8 +79,8 @@ Set in a local or docker-dev environment via the environment variable `ScadaBrid
## LDAP Connection Failure
- **New logins**: If the LDAP/AD server is unreachable, login attempts **fail**. Users cannot be authenticated without LDAP.
-- **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. Interactive cookie sessions never re-query LDAP mid-session (the mid-session role-mapping refresh is DB-only — see *Session Validation* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability. A CLI holding a bearer token likewise keeps working until the token expires; HTTP Basic calls bind per request and fail during the outage.
-- **Recovery (group-membership changes)**: Because the mid-session refresh is LDAP-free, a change to a user's **directory group membership** is picked up at the user's **next login** (when LDAP is queried again), not mid-session — see the *Residual limitation* note above.
+- **Active sessions**: Users with a valid (not-idle-timed-out) session can **continue operating** with their current roles during an LDAP outage. An interactive cookie session's periodic directory group re-query treats an unreachable directory as "keep the stored groups" (see *Directory group re-query* above), so a brief LDAP outage does not disrupt engineers mid-work; central role-mapping changes still apply within the refresh window regardless of LDAP availability. A CLI holding a bearer token likewise keeps working until the token expires; HTTP Basic calls bind per request and fail during the outage.
+- **Recovery (group-membership changes)**: once the directory answers again, the next due re-query (within `LdapGroupRefresh:IntervalMinutes`) applies any group-membership change made during the outage — no re-login needed.
## Roles
diff --git a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs
index ddd8679a..171532db 100644
--- a/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.InboundAPI/EndpointExtensions.cs
@@ -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("");
+ 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
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs
index 98f21a5a..bf54c74e 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/CookieSessionValidator.cs
@@ -37,7 +37,7 @@ public enum SessionValidationAction
/// Leave the principal as-is (no idle timeout, no refresh due, or a refresh error we swallow).
Keep,
- /// The session is idle-timed-out; reject + sign out.
+ /// The session is idle-timed-out, or the directory says the user can no longer sign in; reject + sign out.
Reject,
/// The role mapping was refreshed; replace the principal. The HTTP adapter decides whether renewal is appropriate.
@@ -47,8 +47,10 @@ public enum SessionValidationAction
///
/// The unit-testable core of the cookie OnValidatePrincipal event.
/// Enforces the idle timeout and refreshes the session's role/scope claims from the
-/// STORED LDAP group claims via the DB-backed — without any
-/// LDAP call — picking up central role-mapping (and scope-rule) changes mid-session.
+/// session's LDAP group claims via the DB-backed , picking up central
+/// role-mapping (and scope-rule) changes mid-session. The groups themselves are re-read from the
+/// directory every through
+/// (ScadaBridge#46); in between, the STORED groups are re-mapped.
///
///
///
@@ -67,6 +69,14 @@ public enum SessionValidationAction
/// mapping revoked the user's roles, the rebuilt principal reflects the loss.
///
///
+/// Directory group re-query (ScadaBridge#46): when the session's
+/// 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 — — on every path, authoritative or not.
+///
+///
/// Failure policy: 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 _logger;
private readonly SessionActivityTracker? _activity;
+ private readonly DirectoryGroupRefresher? _directory;
/// Initializes the validator.
/// The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.
@@ -87,12 +98,15 @@ public sealed class CookieSessionValidator
/// Clock source; injected so tests can advance time deterministically.
/// Logger instance.
/// Optional tracker that records session activity for idle-timeout accounting.
+ /// Optional directory group re-query; null re-maps stored groups only.
public CookieSessionValidator(
IGroupRoleMapper roleMapper,
IOptions options,
TimeProvider timeProvider,
- ILogger logger, SessionActivityTracker? activity = null)
+ ILogger 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 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 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);
}
+ ///
+ /// Returns true when the directory group re-query is active and the session's
+ /// anchor is older than
+ /// . A missing/unparsable anchor (a cookie
+ /// issued before ScadaBridge#46) is due. Always false when no
+ /// is active (feature off, no lookup registered).
+ ///
+ /// The cookie principal.
+ /// The current instant.
+ /// true if the groups should be re-read from the directory now.
+ 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;
+
///
/// Returns true when the elapsed time since the last role refresh exceeds
/// . A missing/unparsable
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/DirectoryGroupRefresher.cs b/src/ZB.MOM.WW.ScadaBridge.Security/DirectoryGroupRefresher.cs
new file mode 100644
index 00000000..448ee736
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/DirectoryGroupRefresher.cs
@@ -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;
+
+/// What a directory group re-query means for an active session.
+public enum DirectoryGroupDecision
+{
+ /// The directory answered with the user's current groups; re-map those.
+ UseGroups,
+
+ /// No usable answer (directory unavailable, lookup not supported or switched off); keep the stored groups.
+ KeepStored,
+
+ /// The directory says this user could no longer sign in (gone, disabled, ambiguous, no groups); end the session.
+ EndSession,
+}
+
+/// Outcome of .
+/// What the session validator must do.
+/// The directory's current groups when is ; otherwise empty.
+/// The underlying lookup status, for logging; null when no lookup ran.
+public sealed record DirectoryGroupOutcome(
+ DirectoryGroupDecision Decision,
+ IReadOnlyList Groups,
+ LdapGroupLookupStatus? Status)
+{
+ /// Keep the stored groups; nothing was asked, or nothing usable came back.
+ public static DirectoryGroupOutcome Keep(LdapGroupLookupStatus? status = null) =>
+ new(DirectoryGroupDecision.KeepStored, Array.Empty(), status);
+}
+
+///
+/// Mid-session LDAP group re-query (ScadaBridge#46). Asks the shared library's passwordless
+/// (service-account bind) for a user's current groups and turns the
+/// answer into a .
+///
+///
+///
+/// Failure policy — availability first.
+/// 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. /
+/// (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 — ,
+/// , , or
+/// with zero groups — ends the session.
+///
+///
+/// Answers are reused per user for on this node, so a
+/// user's cookie path and every circuit they hold collapse into one directory query. Singleton.
+///
+///
+public sealed class DirectoryGroupRefresher
+{
+ private readonly ILdapGroupLookup? _lookup;
+ private readonly LdapGroupRefreshOptions _options;
+ private readonly TimeProvider _clock;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _cache =
+ new(StringComparer.OrdinalIgnoreCase);
+ private int _notSupportedLogged;
+
+ /// Initializes the refresher.
+ /// Security options carrying .
+ /// Clock for the cache window.
+ /// Logger.
+ /// The shared library's lookup; null (not registered) behaves as not supported.
+ public DirectoryGroupRefresher(
+ IOptions options,
+ TimeProvider clock,
+ ILogger 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;
+ }
+
+ /// True when the feature is switched on and a lookup is available to ask.
+ public bool IsActive => _options.Enabled && _lookup is not null;
+
+ /// The configured re-query interval.
+ public TimeSpan Interval => TimeSpan.FromMinutes(_options.IntervalMinutes);
+
+ ///
+ /// Re-reads 's groups from the directory. Never throws except for
+ /// cancellation; an unexpected lookup fault is treated as an unavailable directory.
+ ///
+ /// The session's canonical username.
+ /// Cancellation token.
+ /// The decision for the session.
+ public async Task 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;
+ }
+
+ /// Drops every cached answer (tests; an operator-driven reset).
+ 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(), 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);
+ }
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs
index fe324914..78d2d9bf 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/JwtTokenService.cs
@@ -51,6 +51,12 @@ public class JwtTokenService
public const string GroupClaimType = "zb:group";
public const string LastRoleRefreshClaimType = "zb:lastrolerefresh";
+ ///
+ /// Anchor of the last time the session's claims were read from the
+ /// directory (login, or the mid-session LDAP group re-query, ScadaBridge#46). ISO-8601 "o".
+ ///
+ public const string LastGroupRefreshClaimType = "zb:lastgrouprefresh";
+
///
/// 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
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
index e91fa5d5..faa8eaf6 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/SecurityOptions.cs
@@ -80,11 +80,10 @@ public class SecurityOptions
/// 15 minutes, matching the documented sliding-refresh cadence.
///
///
- /// 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,
+ /// (ScadaBridge#46; see Component-Security.md).
///
/// Because a role-refresh is also the only operation that advances the
/// LastActivity anchor, the effective maximum idle window is approximately
@@ -95,6 +94,12 @@ public class SecurityOptions
///
public int RoleRefreshThresholdMinutes { get; set; } = 15;
+ ///
+ /// Mid-session re-query of the user's LDAP group membership (ScadaBridge#46), bound from
+ /// ScadaBridge:Security:LdapGroupRefresh.
+ ///
+ public LdapGroupRefreshOptions LdapGroupRefresh { get; set; } = new();
+
///
/// When true (default) the authentication cookie is always marked
/// Secure (sent only over HTTPS) — the correct production setting,
@@ -161,6 +166,33 @@ public class SecurityOptions
/// so a misconfigured appsettings
/// section is caught at application startup rather than silently misapplied at runtime.
///
+///
+/// Settings for the mid-session LDAP group re-query (ScadaBridge#46). The re-query uses the shared
+/// library's passwordless ILdapGroupLookup, which binds as the configured LDAP service account.
+///
+public sealed class LdapGroupRefreshOptions
+{
+ ///
+ /// On by default. It is effectively inactive — stored groups are kept, nothing is queried — when
+ /// LDAP is configured without a service account (BindMode = User) or disabled, and under
+ /// DisableLogin (no cookie validation runs at all).
+ ///
+ public bool Enabled { get; set; } = true;
+
+ ///
+ /// How often an active session's groups are re-read from the directory, in minutes (default 15,
+ /// the same cadence as ). Measured from the
+ /// session's zb:lastgrouprefresh anchor, which login stamps. Must be at least 1.
+ ///
+ public int IntervalMinutes { get; set; } = 15;
+
+ ///
+ /// 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.
+ ///
+ public int CacheSeconds { get; set; } = 60;
+}
+
public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions
{
/// Validates the bound at application startup, failing fast on invalid combinations.
@@ -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;
}
}
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
index 5adc45b3..19a7ba76 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs
@@ -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();
+ // 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();
services.AddSingleton();
// 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 ,
/// asks it for a decision, and applies it to the cookie context:
///
- /// - → + sign out (idle-timeout — the only sign-out path).
+ /// - → + sign out (idle timeout, or the directory reports the user gone/disabled/ambiguous/group-less — ScadaBridge#46).
/// - → + renewal only for non-passive requests (role mapping refreshed).
/// - → no-op (no refresh due, or a swallowed refresh fault).
///
@@ -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)
diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs b/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs
index 1e2a7eb0..9951b4c2 100644
--- a/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.Security/SessionClaimBuilder.cs
@@ -25,6 +25,8 @@ namespace ZB.MOM.WW.ScadaBridge.Security;
/// — the durable input the mid-session RoleMapper re-run consumes.
/// - — the role-mapping
/// refresh anchor, ISO-8601 round-trippable.
+/// - — when the stored groups
+/// were last read from the directory (login, or the mid-session re-query).
/// - — the idle-timeout
/// anchor; seeded to the refresh timestamp at login so idle-timeout can be
/// enforced consistently from the very first request.
@@ -47,6 +49,11 @@ public static class SessionClaimBuilder
/// The DB-backed role mapping (roles + permitted sites + system-wide flag).
/// The role-mapping refresh anchor; also seeds the last-activity anchor.
/// The authentication type stamped on the identity (defaults to the cookie scheme).
+ ///
+ /// When were read from the directory; defaults to
+ /// (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.
+ ///
/// A fully populated cookie .
public static ClaimsPrincipal Build(
string username,
@@ -54,7 +61,8 @@ public static class SessionClaimBuilder
IReadOnlyList 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)
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs
index 41107151..334ddc26 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Auth/CircuitRevocationRegressionTests.cs
@@ -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(() => commands.ExecuteAsync(new CreateRoleMappingCommand("blocked", Roles.Viewer)));
await transaction.DidNotReceive().ExecuteAsync(Arg.Any>>());
}
+
+ // ---- ScadaBridge#46: directory group re-query reaches a live circuit ----
+
+ private static (AuthenticationStateProvider Auth, ILdapGroupLookup Lookup, ServiceProvider Provider) DirectoryCircuit(
+ IReadOnlyList 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>();
+ mapper.MapAsync(Arg.Any>(), Arg.Any())
+ .Returns(call =>
+ {
+ var groups = call.Arg>();
+ var roles = groups.Contains("SCADA-Admins") ? new[] { Roles.Administrator } : new[] { Roles.Viewer };
+ return new GroupRoleMapping(roles, Scope: new RoleMappingResult(roles, [], true));
+ });
+ var lookup = Substitute.For();
+ var services = new ServiceCollection().AddLogging();
+ services.AddSingleton(mapper);
+ services.AddSingleton(lookup);
+ services.AddSingleton(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();
+ services.AddScoped();
+ services.AddSingleton(new HttpContextAccessor { HttpContext = new DefaultHttpContext { User = initial } });
+ services.AddScoped();
+ var provider = services.BuildServiceProvider();
+ return (provider.CreateScope().ServiceProvider.GetRequiredService(), lookup, provider);
+ }
+
+ /// A directory group removal lands in an open circuit without re-login (ScadaBridge#46).
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SameCircuit_PicksUpDirectoryGroupRemoval_WithoutReLogin()
+ {
+ var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
+ using var _ = provider;
+ lookup.GetUserGroupsAsync("alice", Arg.Any())
+ .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());
+
+ // 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());
+ }
+
+ /// A user disabled in the directory loses the circuit's authentication; a gate then denies.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SameCircuit_DirectoryDisabledUser_BecomesAnonymous()
+ {
+ var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
+ using var _ = provider;
+ lookup.GetUserGroupsAsync("alice", Arg.Any())
+ .Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.UserDisabled));
+
+ var state = await auth.GetAuthoritativeStateAsync();
+
+ Assert.False(state.User.Identity?.IsAuthenticated ?? false);
+ Assert.Empty(state.User.FindAll(JwtTokenService.RoleClaimType));
+ }
+
+ /// An LDAP outage keeps the circuit's roles, even on the fail-closed authoritative path.
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task SameCircuit_DirectoryUnavailable_KeepsRoles()
+ {
+ var (auth, lookup, provider) = DirectoryCircuit(["SCADA-Admins"]);
+ using var _ = provider;
+ lookup.GetUserGroupsAsync("alice", Arg.Any())
+ .Returns(LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable));
+
+ var state = await auth.GetAuthoritativeStateAsync();
+
+ Assert.Contains(state.User.FindAll(JwtTokenService.RoleClaimType), c => c.Value == Roles.Administrator);
+ }
+
+ /// DisableLogin (auto-login) never queries the directory.
+ /// A task that represents the asynchronous operation.
+ [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(), Arg.Any());
+ }
}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs
index 00f2ac98..8f60c5c4 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs
@@ -231,6 +231,32 @@ public sealed class EndpointExtensionsTests : IDisposable
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
+ ///
+ /// Auth 0.4.4 ApiKeyFailure.StoreUnavailable: a key store that throws is a retryable 503,
+ /// never a 401 (which would blame the caller's key) and never an unhandled 500.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task KeyStoreThrows_Returns503_NotUnauthorized()
+ {
+ var method = SeedMethod(1, "echo", "return 1;");
+ var throwingStore = Substitute.For();
+ throwingStore.FindByKeyIdAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ => throw new InvalidOperationException("store down"));
+
+ using var host = await BuildHostAsync(method, additionalServices: services =>
+ {
+ services.RemoveAll();
+ 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());
+ }
+
/// Verifies that a whitespace Authorization header with a valid X-API-Key returns 200.
/// A task that represents the asynchronous operation.
[Fact]
diff --git a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs
index 3896bd94..5e7f53c3 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.IntegrationTests/AuthFlowTests.cs
@@ -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
});
}
+ ///
+ /// 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 ILdapGroupLookup (service account), so the
+ /// directory's groups replace the stored ones without the user's password; a deleted account
+ /// ends the session.
+ ///
+ /// A task that represents the asynchronous operation.
+ [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();
+ 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();
+ 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();
+ Assert.Equal(SessionValidationAction.Reject, (await validator.ValidateAsync(gone)).Action);
+ }
+ }
+
private static async Task IsLdapAvailableAsync()
{
try
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DirectoryGroupRefreshTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DirectoryGroupRefreshTests.cs
new file mode 100644
index 00000000..8e68476e
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DirectoryGroupRefreshTests.cs
@@ -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;
+
+///
+/// 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.
+///
+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;
+ }
+
+ /// Maps each group to a role by name (Admins→Administrator, Designers→Designer, else Viewer).
+ private sealed class NamingMapper : IGroupRoleMapper
+ {
+ public int Calls { get; private set; }
+ public IReadOnlyList? LastGroups { get; private set; }
+
+ public Task> MapAsync(IReadOnlyList 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(roles, Scope: result));
+ }
+ }
+
+ private sealed class FakeLookup : ILdapGroupLookup
+ {
+ public Func Answer { get; set; } =
+ _ => LdapGroupLookupResult.Of(LdapGroupLookupStatus.DirectoryUnavailable);
+ public Exception? Throw { get; set; }
+ public List Calls { get; } = new();
+
+ public Task 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? 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.Instance, withLookup ? lookup : null);
+ var sut = new CookieSessionValidator(mapper, opts, clock, NullLogger.Instance, activity: null, directory: refresher);
+ return new Rig(sut, lookup, mapper, clock, refresher);
+ }
+
+ private static ClaimsPrincipal Login(IReadOnlyList 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 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 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
+ {
+ ["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, NamingMapper>(); // the real one needs the DB repository
+ using var provider = services.BuildServiceProvider();
+ using var scope = provider.CreateScope();
+
+ Assert.True(provider.GetRequiredService().IsActive);
+ Assert.NotNull(scope.ServiceProvider.GetRequiredService());
+ }
+}