153609ef8a
The #96 site-psk-invalidation topic was the first cross-node invalidation broadcast. It is generalised into ONE central DistributedPubSub topic, central-cache-invalidation, carrying typed messages (Commons ICentralCacheInvalidation: SitePskInvalidated, RoleMappingsInvalidated, ExternalDefinitionsInvalidated, InboundApiMethodInvalidated) in an envelope stamped with the publisher's process-unique origin id. - Host: CentralCacheInvalidationBus (ICentralCacheInvalidationPublisher, no-op until the mediator is set, never throws) and CentralCacheInvalidationSubscriber, which skips its own node's envelopes and hands the peer's to every registered ICentralCacheInvalidationHandler (a failing handler does not stop the rest). Replaces SitePskInvalidationSubscriber. - Migration of #96: SitePskRotationInvalidator publishes through the bus and is the PSK handler; SitePskInvalidated keeps its type name and is also published bare on site-psk-invalidation, which the subscriber still listens to, so a mixed-version central pair keeps exchanging PSK invalidations. Remove after one release. - Audit D1: RoleMappingCache is a handler; ManagementActor's post-commit role-mapping invalidation also publishes RoleMappingsInvalidated, so a revocation is immediate on the peer instead of after RoleMappingCacheSeconds. - Audit D3 / #83 (peer half): ExternalDefinitionLookupCache is a handler; the 9 ManagementActor handlers and the Transport bundle importer publish ExternalDefinitionsInvalidated after commit. (#83's local import half was already in.) TTLs remain the fallback for a lost broadcast or a direct SQL edit. Tests: bus and subscriber unit tests (origin filter, legacy topic), peer-node tests through the real caches in ManagementService.Tests, importer publish, composition root, and a real two-node cluster from the production HOCON (CentralCacheInvalidationTwoNodeTests) proving every message type crosses with the production serializer. Docs: Host, Communication, Security, ManagementService, ESG, and the #85 cache audit. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
221 lines
29 KiB
Markdown
221 lines
29 KiB
Markdown
# Component: Security & Auth
|
|
|
|
## Purpose
|
|
|
|
The Security & Auth component handles user authentication via LDAP/Active Directory and enforces role-based authorization across the system. It maps LDAP group memberships to system roles and applies permission checks to all operations.
|
|
|
|
## Location
|
|
|
|
Central cluster. Sites do not have user-facing interfaces and do not perform independent authentication.
|
|
|
|
## Responsibilities
|
|
|
|
- Authenticate users against LDAP/Active Directory using a direct LDAP/AD bind (username/password).
|
|
- Map LDAP group memberships to system roles.
|
|
- Enforce role-based access control on all API and UI operations.
|
|
- Support site-scoped permissions for the Deployer role.
|
|
|
|
## Authentication
|
|
|
|
- **Mechanism**: The Central UI presents a username/password login form. The app validates credentials by binding to the LDAP/AD server with the provided credentials, then queries the user's group memberships.
|
|
- **Transport security**: LDAP connections **must** use LDAPS (port 636) or StartTLS to encrypt credentials in transit. Unencrypted LDAP (port 389) is not permitted.
|
|
- **No local user store**: All identity and group information comes from AD. No credentials are cached locally.
|
|
- **No Windows Integrated Authentication**: The app authenticates directly against LDAP/AD, not via Kerberos/NTLM.
|
|
|
|
## Dev Disable-Login Flag
|
|
|
|
**`ScadaBridge:Security:Auth:DisableLogin`** (bool, default `false`) — when `true`, the Central UI bypasses the login form entirely and auto-authenticates every request as the user named by `ScadaBridge:Security:Auth:User` (default `multi-role`) with all six roles (Administrator, Designer, Deployer, Viewer, Operator, Verifier) granted system-wide. The mechanism is `AutoLoginAuthenticationHandler`, registered under the cookie scheme via `AddSecurity(disableLogin: true)`; because it sits in the cookie scheme, every existing authorization policy authenticates through it with zero policy changes required.
|
|
|
|
**Environment guard (`DisableLoginGuard`).** Because the flag also grants Operator+Verifier — nullifying the two-person secured-write control — `DisableLogin=true` is **refused outside the Development environment**. `DisableLoginGuard.Validate` runs at the Host composition root (before `AddSecurity`) and throws at startup unless the environment is `Development` (case-insensitive) or the operator sets the explicit second acknowledgement key **`ScadaBridge:Security:Auth:AllowOutsideDevelopment`** (bool, default `false`). The ack key exists only for the rare deliberate case of an operator who fully accepts an unauthenticated deployment; absent it, a non-Development host fails fast rather than booting unauthenticated.
|
|
|
|
> **Dev/test ONLY. Never enable in production.**
|
|
|
|
Set in a local or docker-dev environment via the environment variable `ScadaBridge__Security__Auth__DisableLogin=true`. Note that `ScadaBridge:Security:Auth` is a child sub-section nested inside `ScadaBridge:Security`.
|
|
|
|
## Session Management
|
|
|
|
### Interactive sessions — cookie claims ticket
|
|
- On successful interactive login (`POST /auth/login`) the app signs the user in with ASP.NET Core **cookie authentication**. The cookie carries a data-protected **claims ticket** (no embedded JWT): display name, username, roles (`Administrator`, `Designer`, `Deployer`, `Viewer`, `Operator`, `Verifier`), the permitted site IDs of a site-scoped Deployer, the user's raw LDAP groups (`zb:group`) and the idle / role-refresh anchors. The claim shape is built by one shared `SessionClaimBuilder`.
|
|
- A cookie is the correct transport for Blazor Server: persistent SignalR circuits do not carry Authorization headers, and the browser sends the cookie with every SignalR connection and HTTP request.
|
|
- The cookie is **HttpOnly**, `SameSite=Strict`, and **Secure** (requires HTTPS) unless `RequireHttpsCookie` is explicitly turned off for a plain-HTTP dev rig.
|
|
- Both central nodes share the ASP.NET Data Protection key ring, so either node can read a cookie the other issued.
|
|
|
|
### CLI / API sessions — bearer JWT (`POST /auth/token`)
|
|
- `POST /auth/token` (form `username`/`password`) performs the LDAP bind (login-throttled), maps the groups to roles and site scope, and returns a short-lived **HS256 JWT** signed with `SecurityOptions.JwtSigningKey`, shared by both central nodes: `{ access_token, token_type: "Bearer", expires_in, management_bearer: true, username, display_name, roles }`.
|
|
- **JWT claims**: display name, username, roles, and for a site-scoped Deployer the permitted site IDs (no site claims = system-wide, the same normalisation the Basic path uses). Issuer and audience are both fixed to `scadabridge-central`. Lifetime is `JwtExpiryMinutes` (default **15**).
|
|
- `ManagementAuthenticator` accepts `Authorization: Bearer <jwt>` alongside HTTP Basic on every CLI-facing surface: `POST /management`, the audit REST endpoints, and the debug-stream hub (`/hubs/debug-stream`, token in the `Authorization` header of the negotiate request and WebSocket upgrade). Validation checks the HS256 signature, issuer, audience and lifetime (30 s clock skew for node-to-node drift; `nbf` is back-dated by the same skew so a peer whose clock lags the issuer accepts a fresh token after failover); unsigned (`alg=none`) or other-algorithm tokens are rejected, and a failure is a plain `401 AUTH_FAILED` (no LDAP bind, so it does not feed the login throttle). The token is never logged.
|
|
- **Roles and scope inside a valid token are authoritative for its lifetime**: a bearer request performs no LDAP bind and no role re-map. A role revocation therefore reaches a bearer caller when its token expires (≤ `JwtExpiryMinutes`); rotating `JwtSigningKey` invalidates every outstanding token immediately.
|
|
- The CLI exchanges credentials once per process, keeps the token in memory only, re-exchanges once on a `401`, and falls back to HTTP Basic when the server has no bearer support — see `Component-CLI.md`. HTTP Basic stays supported on every surface.
|
|
- Under the dev/test `DisableLogin` flag `/auth/token` answers `404`: the management surfaces already authenticate every request as the dev user, and no credential-free token is minted that another deployment sharing the key might honour.
|
|
- **Signing key**: `StartupValidator` refuses to boot a Central node whose `JwtSigningKey` is missing or shorter than **32 UTF-8 bytes** (256 bits, the HMAC-SHA256 floor). `JwtTokenService` repeats the length check at construction as defence in depth.
|
|
|
|
### 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 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.
|
|
|
|
### 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.
|
|
- Both central nodes share the Data Protection key ring (cookie) and the `JwtSigningKey` (bearer token), so central failover is transparent to a valid cookie or token.
|
|
|
|
## 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.
|
|
|
|
## Roles
|
|
|
|
### Administrator
|
|
- **Scope**: System-wide (always).
|
|
- **Permissions**:
|
|
- Manage site definitions.
|
|
- Manage site-level data connections (define and assign to sites).
|
|
- Manage LDAP group-to-role mappings.
|
|
- Manage API keys (create, enable/disable, delete).
|
|
- System-level configuration.
|
|
- View and export audit logs.
|
|
|
|
### Designer
|
|
- **Scope**: System-wide (always).
|
|
- **Permissions**:
|
|
- Create, edit, delete templates (including attributes, alarms, scripts).
|
|
- Manage shared scripts.
|
|
- Manage external system definitions.
|
|
- Manage database connection definitions.
|
|
- Manage notification lists and SMTP configuration.
|
|
- Manage inbound API method definitions.
|
|
- Manage area definitions per site (any-of with Deployer — see note below).
|
|
- Run on-demand validation (template flattening, script compilation).
|
|
|
|
### Deployer
|
|
- **Scope**: System-wide or site-scoped.
|
|
- **Permissions**:
|
|
- Create and manage instances (overrides, connection bindings, area assignment).
|
|
- Manage area definitions per site (any-of with Designer — see note below).
|
|
- Disable, enable, and delete instances.
|
|
- Deploy configurations to instances.
|
|
- Deploy system-wide artifacts (shared scripts, external system definitions, DB connections, data connections) to all sites.
|
|
- View deployment diffs and status.
|
|
- Use debug view.
|
|
- Manage parked messages.
|
|
- Monitor and manage the Notification Outbox (retry and discard parked notifications).
|
|
- View site event logs.
|
|
- **Site scoping**: A user with site-scoped Deployer role can only perform these actions for instances at their permitted sites.
|
|
- **Administrator overrides site scoping.** A user who holds `Administrator` is system-wide **regardless of the `SiteId` claims they carry** — a scoped Deployer grant does not narrow an Administrator. This is enforced identically on both surfaces: `ManagementActor.EnforceSiteScope` (CLI / HTTP management API) and the Central UI's `SiteScopeService`. The two disagreed until deferred-work register row 86 — the UI was claims-only, so the same user saw the whole fleet through the CLI and only their sites in the UI. **No other role carries this bypass**; in particular `Designer` does not (Designer has no site-scoped permissions to bypass in the first place).
|
|
- **The scope check fails CLOSED on an unresolvable target.** Site scoping works by resolving the command's target (instance id, site id/identifier, data-connection id) and comparing its site to the permitted set. When the target does not resolve, a **site-scoped caller is DENIED** — the same `Unauthorized` result an out-of-scope target produces. The rule is *a target that cannot be PROVEN to sit inside the caller's scope is treated as outside it*; it fails open only for callers who were never scoped (Administrator, or a Deployer with no site grants), who keep their ordinary "not found" answer. The check was fail-open until deferred-work register row 98, which also made the denial **indistinguishable from an out-of-scope denial** — same response shape, and a message that never says whether the target exists — so a scoped caller cannot use the gate to enumerate ids across the fleet. See `Component-ManagementService.md` → *Site-scope gate: fail-closed on an unresolvable target*.
|
|
|
|
> **Area management (arch-review C6)**: Managing area definitions per site is gated **any-of [Designer, Deployer]**, not Administrator. Areas are authored both in the Designer tooling and inside the Central UI Deployment Topology workflow (`Topology.razor` is `RequireDeployment`), so either role qualifies; Administrator is not implicitly included in the actor gate.
|
|
|
|
### Viewer
|
|
- **Scope**: System-wide (always).
|
|
- **Permissions**:
|
|
- Read-only access to operational audit data (Audit Log, Notifications, Site Calls).
|
|
- Navigation to audit pages.
|
|
- Does not grant export or any mutating action.
|
|
|
|
### Operator
|
|
- **Scope**: System-wide (always).
|
|
- **Permissions**:
|
|
- Submit a **secured write** to an MxAccess Gateway connection from the Central UI Secured Writes page (`/operations/secured-writes`) — the *initiating* half of the two-person write workflow. The **Tag path** is selected via the shared `NodeBrowserDialog` tag browser ("Browse…" button; the dialog's address-space search box is hidden for MxGateway connections, which have no search capability). The **Data type** is auto-filled best-effort from the selected tag's Galaxy data type via `SecuredWriteDataTypeMapper` and remains operator-editable.
|
|
- **Purpose**: One of the two distinct global roles backing MxGateway secured writes. An Operator initiates a write; it never executes until a separate **Verifier** approves it. Holding Operator alone never executes a write.
|
|
|
|
### Verifier
|
|
- **Scope**: System-wide (always).
|
|
- **Permissions**:
|
|
- Approve or reject a pending secured write from the Secured Writes page — the *approving* half of the two-person write workflow.
|
|
- **Purpose**: The approving counterpart to **Operator**. Separation of duties is enforced **server-side**: the ManagementActor rejects any approval where the approving user equals the submitting Operator (no self-approval), so the two roles must be held by distinct principals for a write to execute. (See Component-ManagementService.md and Component-CentralUI.md.)
|
|
|
|
> **Two-person secured-write workflow.** `Operator` and `Verifier` are deliberately separate global roles so a single principal cannot both initiate and approve a write through the MxAccess Gateway. Both are coarse global roles like the others. **Site scoping from the LDAP mapping is enforced server-side on every secured-write command** (arch-review R2 N3): submit checks the target site, approve/reject check the row's site (before the TTL/self-approval/CAS chain), and the list constrains a scoped caller to their permitted sites — mirroring the deployment commands' `EnforceSiteScope` rule. Administrators and system-wide principals (empty permitted-site set) are unrestricted.
|
|
>
|
|
> **Deployment-configuration hazard — never grant one principal both roles.** The whole control collapses if the same identity (or LDAP group) maps to *both* `Operator` and `Verifier`: server-side no-self-approval blocks approving the *exact* write you submitted, but a dual-role principal can still trivially pair-approve with a second submission, so a single compromised or careless account regains unilateral write. Keep `SCADA-Operators` and `SCADA-Verifiers` group membership disjoint. The dev `DisableLogin` caveat is the extreme of this: with `DisableLogin` on, the auto-login principal holds **all** roles, so the two-person flow cannot be exercised end-to-end by a single identity — which is exactly why `DisableLogin=true` is refused outside Development (see the `DisableLoginGuard` note above). No-self-approval is covered by handler tests; real two-person use requires two real, distinct-role identities.
|
|
>
|
|
> **Server-side pending TTL (arch-review S2).** A submitted secured write is not valid indefinitely. The ManagementActor enforces a configurable **`SecuredWritePendingTtl`** (`ManagementServiceOptions`, default **24 h**): a `Pending` row older than the TTL is atomically transitioned to **`Expired`** (compare-and-swap, so multi-node sweeps are idempotent) and can never be approved — the stale setpoint is never relayed to the site. Expiry is enforced eagerly at approve/reject (an overdue row fails with an "expired" error instead of executing) and opportunistically swept when the Secured Writes list is queried. A non-positive TTL disables expiry. This closes the "approve a days-old setpoint against a since-changed process" window.
|
|
|
|
## Authorization Policies
|
|
|
|
Role checks are expressed as named ASP.NET Core authorization policies (in `AuthorizationPolicies`), each requiring the matching role claim:
|
|
|
|
| Policy | Role required | Used by |
|
|
|--------|---------------|---------|
|
|
| `RequireAdmin` | Administrator | Site/data-connection management, API keys, LDAP mappings, bundle import, server-certificate trust/remove |
|
|
| `RequireDesign` | Designer | Template authoring, shared scripts, external/DB definitions, notification lists, bundle export |
|
|
| `RequireDeployment` | Deployer | Instance/deployment management, debug view, parked messages, Notification Outbox, Alarm Summary |
|
|
| `RequireOperator` | Operator | Submit a secured write (Secured Writes page) |
|
|
| `RequireVerifier` | Verifier | Approve/reject a pending secured write |
|
|
|
|
`RequireOperator` and `RequireVerifier` were added with the two-person secured-write feature (M7); the rest predate it.
|
|
|
|
## Multi-Role Support
|
|
|
|
- A user can hold **multiple roles simultaneously** by being a member of multiple LDAP groups.
|
|
- Roles are **independent** — there is no implied hierarchy between roles.
|
|
- For example, a user who is a member of both `SCADA-Designers` and `SCADA-Deploy-All` holds both the Designer and Deployer roles, allowing them to author templates and also deploy configurations.
|
|
|
|
## LDAP Group Mapping
|
|
|
|
- System administrators configure mappings between LDAP groups and roles.
|
|
- Examples:
|
|
- `SCADA-Admins` → Administrator role
|
|
- `SCADA-Designers` → Designer role
|
|
- `SCADA-Deploy-All` → Deployer role (all sites)
|
|
- `SCADA-Deploy-SiteA` → Deployer role (Site A only)
|
|
- `SCADA-Deploy-SiteB` → Deployer role (Site B only)
|
|
- `SCADA-Operators` → Operator role (initiates secured writes)
|
|
- `SCADA-Verifiers` → Verifier role (approves secured writes)
|
|
- A user can be a member of multiple groups, granting multiple independent roles.
|
|
- Group mappings are stored in the configuration database and managed via the Central UI (Administrator role).
|
|
- **Stored role casing is canonicalized at the write path** (arch-review C5): the mapping's `Role` arrives as a free string over the CLI/Management API, so `ManagementActor.HandleCreateRoleMapping`/`HandleUpdateRoleMapping` canonicalize it to the exact `Roles.All` casing (e.g. `deployer` → `Deployer`) before persisting, and reject any value not in `Roles.All`. This closes a CLI-works/UI-403s split-brain: the ASP.NET `RequireClaim` policies compare the role claim ordinal case-sensitively while the ManagementActor gates compare case-insensitively, so a row stored with off-canonical casing would pass every actor gate but fail every UI policy. Canonicalizing at this single server-side write path guarantees the two comparisons always agree.
|
|
|
|
## Permission Enforcement
|
|
|
|
- Every API endpoint and UI action checks the authenticated user's roles before proceeding.
|
|
- Site-scoped checks additionally verify the target site is within the user's permitted sites.
|
|
- Unauthorized actions return an appropriate error and are not logged as audit events (only successful changes are audited).
|
|
|
|
## Dependencies
|
|
|
|
- **Active Directory / LDAP**: Source of user identity and group memberships.
|
|
- **Configuration Database (MS SQL)**: Stores LDAP group-to-role mappings and site scoping rules.
|
|
- **Configuration Database (via IAuditService)**: Security/admin changes (role mapping updates) are audit logged.
|
|
|
|
## Interactions
|
|
|
|
- **Central UI**: All UI requests pass through authentication and authorization.
|
|
- **Template Engine**: Designer role enforcement.
|
|
- **Deployment Manager**: Deployer role enforcement with site scoping.
|
|
- **Management Service / Central UI (secured writes)**: `RequireOperator` gates submission and `RequireVerifier` gates approval/rejection of MxGateway secured writes; the no-self-approval rule (Operator ≠ Verifier) is enforced server-side by the ManagementActor, not just in the UI.
|
|
- **All central components**: Role checks are a cross-cutting concern applied at the API layer.
|
|
- **Management Service**: The ManagementActor enforces role-based authorization on every incoming command using the authenticated user identity carried in the message envelope. `ManagementAuthenticator` resolves that identity from either a bearer JWT (issued by `/auth/token` after the same LDAP bind + role mapping) or an HTTP Basic LDAP bind, and the identity (username, roles, permitted sites) travels in every request message. The ManagementActor applies the same role and site-scoping rules as the Central UI — no separate authentication path exists on the server side.
|
|
- **Transport (#24)**: Provides the `RequireDesign` policy (export) and `RequireAdmin` policy (import) enforced at both the Razor page layer and inside the `ZB.MOM.WW.ScadaBridge.Transport` service entrypoints.
|
|
|
|
## Login throttling
|
|
|
|
To blunt online password-spray and brute-force against the directory, every LDAP-bind surface consults a shared in-memory throttle (`LoginThrottle`, a `ZB.MOM.WW.ScadaBridge.Security` singleton) before attempting a bind, and records the bind outcome afterwards.
|
|
|
|
- **Scope — every LDAP-bind surface**: the Central UI interactive login (`POST /auth/login`) and CLI token issue (`POST /auth/token`), plus the HTTP-Basic branch of the management/CLI surfaces fronted by `ManagementAuthenticator` — `POST /management`, the audit REST endpoints, **and the debug-stream hub (`DebugStreamHub.OnConnectedAsync` delegates its whole credential path to `ManagementAuthenticator.AuthenticateAsync`, closing the round-2 N1 gap where the hub bound LDAP directly and bypassed the throttle)**. No bind happens outside these paths; a bearer-token request binds nothing and is not throttled. The dev/test `DisableLogin` bypass sits **above** the throttle: a login-disabled deployment never binds, so it is never throttled.
|
|
- **Key and window**: counters are keyed per `{username}|{IP}` pair (lower-cased username; the IP half is the **forwarded-header-resolved client IP** when `ScadaBridge:Security:ForwardedHeaders` names a trusted proxy, otherwise the raw connection peer). A fixed window of `LoginFailureWindowMinutes` (default 5) opens on the first failure; reaching `MaxLoginFailuresPerWindow` failures (default 5) within it locks that key out for `LoginLockoutMinutes` (default 5). A successful bind clears the key; an expired window resets the count. Setting `MaxLoginFailuresPerWindow` to `0` disables throttling entirely.
|
|
- **Per-IP spray ceiling (arch-review 2026-08-19 P6-9).** The per-pair key alone is blind to a *spray* — one attacker IP trying one password against many distinct usernames never reaches `MaxLoginFailuresPerWindow` on any single pair — so a secondary counter keyed on the IP alone, same window/lockout but a higher ceiling (`PerIpMaxFailures`, default 25, validated strictly greater than `MaxLoginFailuresPerWindow`), locks out *new* attempts from that IP once it is reached; a successful bind does **not** reset the per-IP counter (only window/lockout expiry does), so a spray that stumbles onto one valid account cannot ride that success to keep spraying.
|
|
- **Proxy topologies and the username-lockout DoS.** The key is deliberately `{username}|{IP}`, not `{username}` alone: keying on username alone would let any network-adjacent actor lock any operator out of a SCADA control surface with five wrong passwords, repeatable indefinitely. That isolation only exists when the throttle sees real client IPs — behind a proxy **without** ForwardedHeaders trust, all clients collapse onto the proxy's IP and the DoS returns. Mitigations: (a) the shipped Traefik topologies enable trusted-proxy ForwardedHeaders (so per-IP isolation is real in the documented deployment); (b) the lockout window is short by default (`LoginLockoutMinutes` = 5) and never touches the directory account itself; (c) residual risk — an attacker spraying from their *own* IP locking out a victim username *at that IP only* — is the throttle working as designed. A success-path bypass ("a correct password unlocks") was considered and rejected: it would let an attacker who has the password bypass the lockout entirely, and it converts the throttle into a password oracle during the lockout window.
|
|
- **Behaviour when locked**: the surface refuses the bind without contacting LDAP. The Basic-Auth surfaces (`ManagementAuthenticator`) and `POST /auth/token` return HTTP `429` with `code = "AUTH_THROTTLED"`; `POST /auth/login` redirects to `/login` with a "Too many failed attempts. Try again later." message.
|
|
- **Best-effort, per-node**: the throttle is in-memory with no external state. The two central nodes maintain independent counters (a spray hitting both simply burns its budget on each). Memory is bounded — writes prune expired keys and cap the tracked-key count. This is a rate-limit guard, not an account-lockout policy; it never disables the directory account itself.
|
|
|
|
### Passive traffic and circuit refresh (2026-09-09)
|
|
|
|
`/auth/ping`, `/auth/activity`, and Blazor transport validation do not advance inactivity timestamps or trigger sliding renewal. The explicit activity endpoint validates antiforgery before renewing the cookie and recording activity by protected session ID. A bounded process-local tracker lets a live circuit observe successful activity renewal. Role refresh preserves the session ID and activity timestamp; current circuit mutation checks re-map stored groups with fail-closed lookup behavior. Display-only auth-state reads reuse the circuit's last revalidation for up to 10 s (review 2026-10-21 07/P-3), so an idle timeout or a revoked grant can show on a read-only surface for at most that long; it never reaches a mutation.
|