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:
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user