diff --git a/docs/plans/2026-06-11-adminui-disable-login-design.md b/docs/plans/2026-06-11-adminui-disable-login-design.md new file mode 100644 index 00000000..63c6ed23 --- /dev/null +++ b/docs/plans/2026-06-11-adminui-disable-login-design.md @@ -0,0 +1,204 @@ +# AdminUI "Disable Login" Dev Flag — Design + +**Date:** 2026-06-11 +**Status:** Approved (brainstorming) — ready for implementation plan. + +## Goal + +A config flag that **disables login in the AdminUI web app**. When enabled, every +request is auto-authenticated as the fixed user **`multi-role-test`** holding **all** +roles, so no login form / cookie / LDAP bind is involved. Default **off**; enabled in +the local **docker-dev** environment for testing. + +## Why / scope + +Speeds up local AdminUI testing (no sign-in round-trip, no GLAuth dependency). Scope is +the **AdminUI cookie web surface only** — the OPC UA server's LDAP auth and the deploy +API's `X-Api-Key` are separate auth paths and are **untouched**. + +## Background (current auth, verified) + +- AdminUI auth is a **single cookie scheme** registered in + `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs::AddOtOpcUaAuth` + (`AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(...)`). +- Authorization has a **`FallbackPolicy = RequireAuthenticatedUser()`** (every page needs + auth) plus policies **`FleetAdmin`** (→ role `Administrator`) and **`DriverOperator`** + (→ `Operator` or `Administrator`). **All three name the cookie scheme explicitly** + (`AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)`). +- Login flow: `/login` page → `POST /auth/login` (`Security/Endpoints/AuthEndpoints.cs`) + → `ILdapAuthService` → `IGroupRoleMapper` → builds a `ClaimsPrincipal` + (`ZbClaimTypes.Name/Username/DisplayName` + `ZbClaimTypes.Role` per role) → + `SignInAsync` cookie. +- Blazor reads auth via the custom **`CookieAuthenticationStateProvider`** + (`Security/Blazor/`), which **snapshots the boot request's `HttpContext.User`** and + then only *invalidates* it by polling `/auth/ping`. +- **Single source of truth:** `HttpContext.User`. Populate it for every request and BOTH + the HTTP pipeline (pages, `/api/script-analysis/*` `FleetAdmin` endpoints) and the + Blazor circuit (`AuthorizeView`, `[CascadingParameter] AuthenticationState`, policy + checks) are covered by one seam. +- Roles: `AdminRole` enum = `Viewer`, `Designer`, `Administrator` + (`Core/.../Configuration/Enums/AdminRole.cs`), plus the appsettings-only control-plane + string **`Operator`** (used by the `DriverOperator` policy). Granting all four satisfies + every policy and every role-gated `AuthorizeView`. + +## Approach (chosen: always-authenticating handler under the cookie scheme name) + +When the flag is **on**, **replace the `AddCookie(...)` registration with a custom +`AuthenticationHandler` registered under the *same* scheme name** +(`CookieAuthenticationDefaults.AuthenticationScheme`). Its `HandleAuthenticateAsync` +**always returns `AuthenticateResult.Success`** with the `multi-role-test` principal +(all roles). + +Registering under the cookie scheme name (not a new name) is the load-bearing detail: +the `FallbackPolicy`, `FleetAdmin`, and `DriverOperator` policies all *name that scheme*, +so they authenticate through the auto-login handler and pass — **no policy or page code +changes**. `UseAuthentication()` stamps `HttpContext.User` on every request, so the +circuit-boot snapshot and all minimal-API endpoints get the same principal. + +Alternatives rejected: (2) stub the `AuthenticationStateProvider` + a pipeline middleware +— two seams to keep in sync, and a page request still 302s to `/login` unless +`HttpContext.User` is also set; (3) extend the existing `Security:Ldap:DevStubMode` to +auto-redirect — still routes through the login form + cookie sign-in and grants only +`Administrator`. + +## Components + +### 1. Options — `AuthDisableLoginOptions` + +New options class (in `ZB.MOM.WW.OtOpcUa.Security`), bound to a new `Security:Auth` +section: + +```csharp +public sealed class AuthDisableLoginOptions +{ + public const string SectionName = "Security:Auth"; + public bool DisableLogin { get; set; } // default OFF + public string User { get; set; } = "multi-role-test"; // the impersonated user +} +``` + +Roles are **not** configurable (YAGNI) — when the flag is on the principal always carries +**all** canonical roles. The role set is centralized in one place (the enum values of +`AdminRole` + the `Operator` literal) so a future role addition updates one spot. + +```jsonc +// appsettings.json (default) +"Security": { "Auth": { "DisableLogin": false } } +``` + +### 2. Handler — `AutoLoginAuthenticationHandler : AuthenticationHandler` + +`HandleAuthenticateAsync` builds: + +```csharp +var claims = new List { + new(ZbClaimTypes.Name, user), // == ClaimTypes.Name → Identity.Name + new(ZbClaimTypes.Username, user), + new(ZbClaimTypes.DisplayName, user), +}; +foreach (var role in AllRoles) // Viewer, Designer, Operator, Administrator + claims.Add(new Claim(ZbClaimTypes.Role, role)); // == ClaimTypes.Role +var principal = new ClaimsPrincipal( + new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme)); +return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); +``` + +Mirrors the claim shape `AuthEndpoints` already produces, so downstream code (Account +page, audit actor accessor, policies) sees an identical principal — just minted without a +credential check. + +### 3. Wiring — branch in `AddOtOpcUaAuth` + +```csharp +services.AddOptions().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName)); +var disableLogin = configuration.GetSection(AuthDisableLoginOptions.SectionName) + .GetValue(nameof(AuthDisableLoginOptions.DisableLogin)); + +var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); +if (disableLogin) +{ + // LOUD warning — see Guardrails. + authBuilder.AddScheme( + CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); +} +else +{ + authBuilder.AddCookie(o => { o.LoginPath = "/login"; o.LogoutPath = "/auth/logout"; }); +} +``` + +The cookie-options `PostConfigure`, DataProtection, LDAP services, JWT, and the +`AddAuthorization` policy block are **left exactly as-is** — they're harmless when the +flag is on (`/auth/login` would still work if hit, just pointless), and untouched when +off. + +## Data flow (flag on) + +``` +any HTTP request → UseAuthentication() → AutoLoginAuthenticationHandler.HandleAuthenticateAsync + → HttpContext.User = multi-role-test {Viewer,Designer,Operator,Administrator} + ├─ page request: FallbackPolicy.RequireAuthenticatedUser ✓ → page renders + ├─ /api/script-analysis/*: RequireAuthorization("FleetAdmin") → role Administrator ✓ + └─ Blazor circuit boot: snapshots HttpContext.User → CookieAuthenticationStateProvider._current + → AuthorizeView / [Authorize(Policy=...)] / IAuthorizationService all pass + /auth/ping → authenticated → 2xx → ping loop never invalidates the circuit +``` + +## Guardrails / error handling + +- **Default off.** Production deployments are unaffected unless someone explicitly sets + `Security:Auth:DisableLogin=true`. +- **Works in any environment** (not hard-gated to `Development`) — docker-dev containers + run as `Production`, and a hard-gate would silently no-op exactly where it's wanted. +- **Loud startup warning** when on: `LogWarning("⚠ AdminUI LOGIN DISABLED — every request + is authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only — never set + Security:Auth:DisableLogin=true in production.")`. +- **Cosmetic edges (no action needed):** `/login` is never reached (nothing challenges); + manually visiting it still shows the form but is harmless. `/auth/logout` clears a + non-existent cookie and the next request re-authenticates anyway. These are acceptable + for a dev flag; not worth special-casing. + +## docker-dev enablement + +Add to the `environment:` block of **both** AdminUI nodes in +`docker-dev/docker-compose.yml` — `central-1` (line ~139) and `central-2` (line ~179), +the only `OTOPCUA_ROLES=admin,driver` services: + +```yaml + Security__Auth__DisableLogin: "true" +``` + +(`docker-compose.yml` is an existing tracked file that already carries the dev secrets — +no new secret is introduced.) After the edit, `lmxopcua`/docker-dev rebuild picks it up; +`/` loads straight into the app as `multi-role-test` with full access, no sign-in. + +## Testing + +No bUnit. In `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` (alongside +`OtOpcUaLdapAuthServiceTests`): + +- **Handler unit tests:** `AutoLoginAuthenticationHandler` returns `Success` with + `Identity.Name == "multi-role-test"` (or the configured `User`) and role claims for + **all four** canonical roles; the principal satisfies `FleetAdmin` and `DriverOperator` + (assert via `IAuthorizationService`/policy evaluation or by checking `IsInRole`). +- **Wiring test:** with `DisableLogin=true`, the registered authentication handler for the + cookie scheme is the auto-login handler (and the cookie handler when off). A small DI + resolution assertion. +- **Live proof:** docker-dev `/run` — `/` loads authenticated as `multi-role-test`, + `FleetAdmin`-gated pages (e.g. RoleGrants) and `DriverOperator` actions are available + with no sign-in. User drives; agent does not sign in (no sign-in needed when the flag is + on — that's the point). + +## Out of scope + +- OPC UA server LDAP auth; deploy API key auth. +- Configurable role subsets / multiple impersonated users (YAGNI). +- Hard environment gating (revisit only if the flag risks leaking to a real deployment). + +## Hard rules + +Stage by explicit path (never `git add .`); never stage `sql_login.txt` / +`src/Server/.../Host/pki/`; never echo the gateway API key into a **new** tracked file +(the compose edit touches an existing file that already has it); no force-push, no +`--no-verify`; **no Configuration entity / EF migration change**. Build on a feature +branch off `master`.