From 5cf2d1cb99023748b6c8db306df46d4cdd4392cf Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 16 Jun 2026 08:31:19 -0400 Subject: [PATCH] docs(plan): design for dev disable-login auto-login flag (port from OtOpcUa) Faithful copy (warning only, no env guard); custom AuthenticationHandler under the cookie scheme; reuses M2.19 SessionClaimBuilder for an all-roles system-wide principal. --- docs/plans/2026-06-16-disable-login-design.md | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/plans/2026-06-16-disable-login-design.md diff --git a/docs/plans/2026-06-16-disable-login-design.md b/docs/plans/2026-06-16-disable-login-design.md new file mode 100644 index 00000000..5171a0df --- /dev/null +++ b/docs/plans/2026-06-16-disable-login-design.md @@ -0,0 +1,171 @@ +# Disable-Login (Dev Auto-Login) Flag — Design + +**Date:** 2026-06-16 +**Status:** Approved (design); implementation plan to follow. +**Branch:** `feature/disable-login` (off `main`, which now contains the merged M2 work). + +## Goal + +Port the sister project **OtOpcUa**'s "disable login" config flag to ScadaBridge: a dev/test +flag that, when enabled, bypasses the login form entirely and auto-authenticates **every +request** as the `multi-role` user with **all** ScadaBridge roles (full permissions, +system-wide / all sites). Default OFF. + +## Scope decisions (confirmed with user) + +- **Safety guarding:** *Faithful copy* of OtOpcUa — **no environment guard**. The flag works in + any `ASPNETCORE_ENVIRONMENT`; the only protection is a loud `LogWarning` on startup. (Noted + risk: unlike OtOpcUa's AdminUI, this disables authentication on a SCADA **control** surface — + the warning wording is emphatic and the doc-comment says "never enable in production." No code + guard, by explicit choice.) +- **Workflow:** the completed M2 (Tier-2) work was merged to `main` first (`b2d8fd8`); this + feature is built on `feature/disable-login` off that updated main, so it can reuse M2.19's + `SessionClaimBuilder`. +- **Mechanism:** a custom ASP.NET Core `AuthenticationHandler` registered **under the cookie + scheme name** when the flag is set (OtOpcUa's exact mechanism) — not request-middleware, not a + Blazor `AuthenticationStateProvider` override. + +## Reference: how OtOpcUa does it + +(Verified read-only against `/Users/dohertj2/Desktop/OtOpcUa`.) + +- Config key `Security:Auth:DisableLogin` (bool) + `Security:Auth:User` (string, default + `"multi-role-test"`), bound to `AuthDisableLoginOptions` (`SectionName = "Security:Auth"`). +- `AutoLoginAuthenticationHandler` — `HandleAuthenticateAsync` mints a `ClaimsPrincipal` with + `Name`/`Username`/`DisplayName` + one `Role` claim per `DevAuthRoles.All` (all enum role names + + `"Operator"`), returns `AuthenticateResult.Success`. **No cookie written** — every request + re-authenticates fresh. `SignInAsync`/`SignOutAsync` are explicit **no-ops** (required, because + `/auth/logout` calls `HttpContext.SignOutAsync()` on the scheme; without the no-ops the + framework throws casting the handler to a sign-out handler). +- Wiring (startup): reads the flag; if true, `AddAuthentication(Cookies)` then + `.AddScheme(Cookies, _ => {})` + **instead of** `.AddCookie(...)`. Since every authorization policy names the cookie scheme, + they all authenticate through the handler — **zero policy changes**. +- Safety: **no environment guard**; a `PostConfigure` emits one loud + `LogWarning` naming the user + roles. The login page is not hidden — it is simply never reached + because every request is already authenticated. + +## ScadaBridge adaptation + +ScadaBridge has its own role vocabulary and (post-M2.19) a canonical claim builder; the port +adapts to both. + +### 1. Config / options + +New `AuthDisableLoginOptions` in `ZB.MOM.WW.ScadaBridge.Security`: + +```csharp +public sealed class AuthDisableLoginOptions +{ + public const string SectionName = "ScadaBridge:Security:Auth"; + public bool DisableLogin { get; set; } // default false + public string User { get; set; } = "multi-role"; // canonical ScadaBridge test user +} +``` + +`appsettings.json` ships with the section absent or `"DisableLogin": false`. The flag is only set +`true` in local/dev configs (e.g. a docker-dev env var `ScadaBridge__Security__Auth__DisableLogin=true`). + +### 2. Roles — single source of truth + +ScadaBridge declares exactly four roles in `Roles.cs`: `Administrator`, `Designer`, `Deployer`, +`Viewer`. Add a `Roles.All` array (mirrors OtOpcUa's `DevAuthRoles.All`) so "all permissions" +stays in sync if a role is added: + +```csharp +public static readonly string[] All = [Administrator, Designer, Deployer, Viewer]; +``` + +### 3. Auto-login handler + +`AutoLoginAuthenticationHandler` in `ZB.MOM.WW.ScadaBridge.Security`, deriving from +`SignInAuthenticationHandler` (so it satisfies the sign-in/sign-out +handler contract). It reuses **M2.19's `SessionClaimBuilder.Build(...)`** for claim parity with a +real login: + +```csharp +var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role" : _opts.User; +var mapping = new RoleMappingResult(Roles.All, [], IsSystemWideDeployment: true); +var principal = SessionClaimBuilder.Build( + username: user, displayName: user, groups: [], mapping: mapping, refreshTimestamp: now); +return AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name)); +``` + +This yields: `Name`/`Username`/`DisplayName` = the user; all four `Role` claims; +**no `ScopeId` claims** (`IsSystemWideDeployment: true` ⇒ system-wide, all sites). The +`LastActivity`/`LastRoleRefresh` stamps that `SessionClaimBuilder` adds are harmless here (the +auto-login scheme has no `OnValidatePrincipal`, so M2.19's idle/refresh logic is simply bypassed — +correct for a dev bypass). `now` comes from the injected `TimeProvider`. + +`SignInAsync` and `SignOutAsync` are **no-ops** (so `/auth/logout` does not throw). `_opts` is the +resolved `AuthDisableLoginOptions`. + +### 4. Wiring + +The scheme choice is build-time and config-coupled, mirroring how `AddZbLdapAuth(configuration, …)` +is called at the Host root immediately before `AddSecurity()`. Thread the flag into `AddSecurity` +(re-add a minimal parameter — the shape it had before the LDAP cutover, e.g. +`AddSecurity(this IServiceCollection services, bool disableLogin = false)`): + +- **flag false (default):** unchanged — `AddAuthentication(Cookies).AddCookie(...)` with the + existing cookie hardening + M2.19 `OnValidatePrincipal`. +- **flag true:** `AddAuthentication(Cookies).AddScheme(Cookies, _ => {})` instead of `AddCookie`, and emit the loud + startup `LogWarning`. + +`Program.cs` (Host) reads `ScadaBridge:Security:Auth:DisableLogin` from `builder.Configuration` +and passes it to `AddSecurity(...)`. `AddOptions().Bind(section)` is +registered so the handler can resolve `User`. + +### 5. Safety (faithful copy — warning only) + +No environment guard. One emphatic `LogWarning` at startup when the flag is on, e.g.: + +> "AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is authenticated as +> '{User}' with FULL permissions ({Roles}) across ALL sites. This is a SCADA control surface; +> dev/test ONLY — never enable in production." + +The `AuthDisableLoginOptions` doc-comment repeats "Default OFF. Never enable in production." + +### 6. Unchanged + +Login page, `/auth/login`, `RoleMapper`, LDAP (`AddZbLdapAuth`), cookie hardening, and the M2.19 +cookie session validator are all untouched. In disable-login mode the login-redirect path is +never reached because every request is pre-authenticated. + +## Error handling + +- `HandleAuthenticateAsync` always returns success — it cannot fail (no I/O, no LDAP, no DB). +- No-op sign-in/out cannot throw. +- If `User` is blank, fall back to `"multi-role"`. + +## Testing + +xUnit, in `ZB.MOM.WW.ScadaBridge.Security.Tests` (mirroring the existing harness): + +1. **Handler** — `HandleAuthenticateAsync` returns an authenticated principal whose identity name + is the configured user, carries all four `Roles.All` role claims, and has **no `ScopeId`** + claim (system-wide). A blank/whitespace `User` falls back to `multi-role`. +2. **No-op sign-in/out** — `SignInAsync`/`SignOutAsync` complete without throwing. +3. **Registration switch** — with the flag true the registered handler for the cookie scheme is + `AutoLoginAuthenticationHandler`; with the flag false it is the cookie handler (assert via the + resolved `IAuthenticationSchemeProvider` / scheme `HandlerType`). +4. (Optional) **Claim parity** — the handler's principal matches a real-login principal shape for + an all-roles system-wide user (reuse the M2.19 `SessionClaimBuilderParityTests` idiom). + +## Files (anticipated) + +- **New:** `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs`, + `.../Auth/AutoLoginAuthenticationHandler.cs`. +- **Modify:** `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs` (`All` array), + `.../ServiceCollectionExtensions.cs` (flag param + scheme switch + warning + options bind), + `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (read flag, pass to `AddSecurity`). +- **Tests:** `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs` + (+ a registration-switch test). +- **Docs/deploy (optional):** note the flag in the Security component doc / docker-dev appsettings. + +## Out of scope + +- Any production safety guard (explicitly declined). +- Hiding/redirecting the login page (unnecessary — it is unreachable when auth always succeeds). +- Bearer-token (`/auth/token`) path — unaffected; this is the cookie/interactive path only.