docs: reconcile stale cluster/security/notification docs; close register row 117, add rows 128-135 (review 2026-10-21 wave 2)

This commit is contained in:
Joseph Doherty
2026-10-22 09:50:37 -04:00
parent 46ab5594ec
commit b67d736ac9
10 changed files with 45 additions and 44 deletions
+3 -3
View File
@@ -177,12 +177,12 @@ Options are bound via the .NET Options pattern (`IOptions<T>`). Each component o
| Key | Default | Description |
|-----|---------|-------------|
| `SeedNodes` | — | List of Akka seed-node URIs (`akka.tcp://scadabridge@host:port`). At least 2 required. Must reference remoting ports, not gRPC ports. |
| `SplitBrainResolverStrategy` | `keep-oldest` | Active strategy name (e.g. `"keep-oldest"`). |
| `StableAfter` | `"00:00:15"` | Duration the cluster must be stable before the resolver acts. |
| `SplitBrainResolverStrategy` | `auto-down` | Downing strategy: `"auto-down"` (default) or `"keep-oldest"`. |
| `StableAfter` | `"00:00:15"` | Sustained unreachability before downing (`auto-down-unreachable-after` under `auto-down`). |
| `HeartbeatInterval` | `"00:00:02"` | Akka failure-detector heartbeat cadence. |
| `FailureDetectionThreshold` | `"00:00:10"` | Acceptable heartbeat pause before a node is considered unreachable. |
| `MinNrOfMembers` | `1` | Minimum cluster members before the leader is elected. |
| `DownIfAlone` | `true` | When using `keep-oldest`, whether a lone surviving node downs itself. |
| `DownIfAlone` | `true` | `keep-oldest` only; inert under `auto-down`. |
### `ScadaBridge:Database` → `DatabaseOptions`
+2 -2
View File
@@ -20,7 +20,7 @@ Before the current design, site nodes delivered notifications directly over SMTP
This means:
- Credential exposure is limited to the central cluster.
- List membership is resolved at delivery time, so a list change takes effect for all future deliveries without redeploying to sites.
- The SMTP `MaxConcurrentConnections` value is configured at a single point, though it is not currently enforced (no connection gate or semaphore).
- The SMTP `MaxConcurrentConnections` value is configured at a single point and now clamps the Notification Outbox dispatcher's Email delivery parallelism (review 2026-10-21 06/IMPL-2); it is still unused for the EWS transport.
### `NotificationType` discriminator
@@ -156,7 +156,7 @@ services.AddNotificationOutbox(); // registers EmailNotificationDeliveryAdapte
| Section | Key | Default | Description |
|---------|-----|---------|-------------|
| `ScadaBridge:Notification` | `ConnectionTimeoutSeconds` | `30` | SMTP connection/operation timeout in seconds. Applied when `SmtpConfiguration.ConnectionTimeoutSeconds` is zero or negative. |
| `ScadaBridge:Notification` | `MaxConcurrentConnections` | `5` | Maximum concurrent SMTP connections. Used as the documented fallback default when the `SmtpConfiguration` row is unset; this limit is not currently enforced by a connection gate or semaphore. |
| `ScadaBridge:Notification` | `MaxConcurrentConnections` | `5` | Maximum concurrent SMTP connections. Used as the fallback default when the `SmtpConfiguration` row is unset; the outbox dispatcher clamps Email parallelism to it (unused under EWS). |
SMTP retry settings (`MaxRetries`, `RetryDelay`) live on the `SmtpConfiguration` entity and are read by the Notification Outbox dispatcher — they are not part of `NotificationOptions`.
+16 -23
View File
@@ -1,6 +1,6 @@
# Security & Auth
The Security & Auth component handles user authentication against an LDAP/Active Directory server and enforces role-based authorization across all central cluster operations. It owns the cookie+JWT hybrid session model, the LDAP-group-to-role mapping pipeline, site-scoped deployment permissions, and the inbound API key management seam.
The Security & Auth component handles user authentication against an LDAP/Active Directory server and enforces role-based authorization across all central cluster operations. It owns the cookie session model (interactive UI) and the bearer-JWT model (CLI/API), the LDAP-group-to-role mapping pipeline, site-scoped deployment permissions, and the inbound API key management seam.
## Overview
@@ -8,7 +8,7 @@ Security & Auth (#10) runs exclusively on the central cluster — sites have no
The component registers:
- `JwtTokenService` — token generation, validation, idle-timeout enforcement, and sliding refresh logic.
- `JwtTokenService` — mints and validates the short-lived HS256 **bearer** JWT issued by `POST /auth/token` for CLI/API callers (it no longer participates in the interactive cookie session; the sliding-refresh/idle members were removed in the 2026-10-21 review remediation).
- `RoleMapper` — DB-backed LDAP-group-to-role resolution with site-scope union semantics.
- `ScadaBridgeGroupRoleMapper` — adapter exposing `RoleMapper` on the shared `IGroupRoleMapper<string>` seam.
- `HttpAuditActorAccessor` — resolves the authenticated username from the ambient HTTP context for audit `Actor` stamping.
@@ -21,15 +21,15 @@ 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 session (valid cookie+JWT) continues with its current claims until the JWT expires. This avoids disrupting engineers mid-work during a brief directory outage. When the directory recovers, the next token refresh re-queries groups and issues a fresh token.
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.
### Cookie+JWT hybrid session
### Sessions: cookie (UI) and bearer JWT (CLI/API)
On successful login the server mints a JWT via `JwtTokenService.GenerateToken` and writes it into an HttpOnly/Secure authentication cookie. The cookie is the transport — not a bearer header — because Blazor Server's persistent SignalR circuits do not carry `Authorization` headers on reconnect. The browser sends the cookie on every HTTP and SignalR request automatically.
**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).
The JWT is signed with HMAC-SHA256 using a shared symmetric key (`JwtSigningKey`). Both central nodes share the same key, so either node can issue and validate tokens without a shared session store; the load balancer needs no sticky-session configuration. `ClockSkew` is set to `TimeSpan.Zero` to close the standard five-minute tolerance window.
**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`.
Claims embedded in every token:
Claims in the bearer token:
| Claim type | Constant | Value |
|---|---|---|
@@ -37,15 +37,8 @@ Claims embedded in every token:
| `ZbClaimTypes.Username` | `JwtTokenService.UsernameClaimType` | Authenticated username |
| `ClaimTypes.Role` (URI) | `JwtTokenService.RoleClaimType` | One claim per role |
| `ZbClaimTypes.ScopeId` | `JwtTokenService.SiteIdClaimType` | One claim per permitted site (Deployer only) |
| `"LastActivity"` | `JwtTokenService.LastActivityClaimType` | ISO 8601 idle-timeout anchor |
`MapOutboundClaims` is cleared on the mint path (`GenerateToken`) so `JwtSecurityTokenHandler` writes claim type strings verbatim into the token. On the validate path (`ValidateToken`) only `MapInboundClaims = false` is set — the outbound map is not touched. Together these settings prevent the default claim-type rewrite maps from transforming the canonical role URI or `zb:` claim types, keeping the type strings byte-for-byte identical in the token and in every policy check.
### Token lifecycle and idle timeout
The JWT lifetime (`JwtExpiryMinutes`, default 15 minutes) and the cookie idle window (`IdleTimeoutMinutes`, default 30 minutes) are separate layers. ASP.NET cookie auth's `SlidingExpiration = true` with `ExpireTimeSpan = IdleTimeout` models the idle window: the middleware re-issues the cookie once the session passes its halfway mark, keeping active users signed in. The JWT within that cookie has its own 15-minute expiry.
`JwtTokenService.ShouldRefresh` checks whether remaining JWT lifetime is below `JwtRefreshThresholdMinutes` (default 5 minutes). `RefreshToken` issues a fresh JWT while **preserving** the existing `LastActivity` anchor — a background refresh is not treated as user activity. `RecordActivity` advances the anchor to now. `IsIdleTimedOut` checks whether the elapsed time since `LastActivity` exceeds `IdleTimeoutMinutes`; `RefreshToken` enforces the idle check internally so an idle-expired session cannot be kept alive by background polling regardless of caller discipline (Security-014).
Claim-type maps are disabled on mint and validate (`MapInboundClaims = false`), so the canonical role URI and `zb:` claim types stay byte-identical in the token and in every policy check; validation allows a 30 s clock skew for node-to-node drift.
## Architecture
@@ -143,10 +136,9 @@ Login flow (Central UI `/auth/login` and `/auth/token`):
1. Call `ILdapAuthService.AuthenticateAsync(username, password)` (registered by Host via `AddZbLdapAuth`).
2. On success, call `RoleMapper.MapGroupsToRolesAsync(ldapGroups)` to resolve roles and site scope.
3. Call `JwtTokenService.GenerateToken(displayName, username, roles, permittedSiteIds)` to mint a signed JWT.
4. Write the JWT into the HttpOnly cookie via the ASP.NET cookie auth `SignInAsync`.
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 request, middleware reads the cookie, validates the embedded JWT with `JwtTokenService.ValidateToken`, and checks `IsIdleTimedOut`. If the token is near expiry (`ShouldRefresh`), fresh claims are re-queried from LDAP and `RefreshToken` issues a replacement. Genuine user interactions call `RecordActivity` to advance the last-activity anchor.
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.
Authorization gates use the named policies:
@@ -167,10 +159,11 @@ if (!AuthorizationPolicies.OperationalAuditRoles.Contains(userRole))
| Key | Default | Description |
|---|---|---|
| `JwtSigningKey` | *(required)* | Symmetric HMAC-SHA256 signing key. Must be at least 32 bytes (256 bits); validated at `JwtTokenService` construction — startup fails if too short. |
| `JwtExpiryMinutes` | `15` | JWT lifetime in minutes before embedded token expires and must be refreshed. |
| `JwtRefreshThresholdMinutes` | `5` | Minutes before JWT expiry at which `ShouldRefresh` triggers a re-issue. |
| `IdleTimeoutMinutes` | `30` | Session idle timeout in minutes. Cookie `ExpireTimeSpan` is set to this value; `IsIdleTimedOut` enforces it from the `LastActivity` claim. |
| `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). |
| `RoleMappingCacheSeconds` | `30` | Process-wide `RoleMappingCache` TTL (`0` disables); invalidated on the local node after mapping/scope-rule edits. |
| `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. |
@@ -211,7 +204,7 @@ Maps from `LdapAuthFailure.GroupLookupFailed`. The user-credential bind succeede
### Session expires unexpectedly
Check `IdleTimeoutMinutes` and `JwtExpiryMinutes` in `SecurityOptions`. A background refresh that fires while the user is idle preserves the `LastActivity` anchor (`RefreshToken` does not advance it); `IsIdleTimedOut` enforces the window from the last genuine user interaction. If the idle timeout fires before the expected window, confirm that `RecordActivity` is being called on genuine user requests.
Check `IdleTimeoutMinutes` and `JwtExpiryMinutes` in `SecurityOptions`. Passive traffic (`/auth/ping`, Blazor transport validation) does not renew the cookie; only the antiforgery-protected `/auth/activity` endpoint does. If the idle timeout fires earlier than expected, confirm genuine user interaction reaches that endpoint.
### Two ScadaBridge environments on the same host clobber each other's session
+1 -1
View File
@@ -38,7 +38,7 @@ The identical oldest-member check backs `ActiveNodeGate` — the `IActiveNodeGat
### Automatic failover
When the active central node goes down, the Akka cluster's keep-oldest split-brain resolver leaves the surviving node as the oldest Up member and the singletons migrate to it (roughly 25 seconds: 10-second heartbeat threshold plus a 15-second stable-after period). Once the surviving node's `OldestNodeActiveHealthCheck` starts returning 200, Traefik's next poll cycle — within the 5-second interval — removes the failed backend from the pool and routes all subsequent requests to the new active node. No config change or restart is required on the Traefik side.
When the active central node goes down, the Akka cluster's `auto-down` downing strategy (the leader among the reachable members downs the dead node) leaves the surviving node as the oldest Up member and the singletons migrate to it (roughly 25 seconds: 10-second heartbeat threshold plus a 15-second `auto-down-unreachable-after` window). Once the surviving node's `OldestNodeActiveHealthCheck` starts returning 200, Traefik's next poll cycle — within the 5-second interval — removes the failed backend from the pool and routes all subsequent requests to the new active node. No config change or restart is required on the Traefik side.
## Architecture