Files
lmxopcua/docs/plans/2026-06-11-adminui-disable-login-design.md
T
Joseph Doherty 789176738f docs(adminui): design for Security:Auth:DisableLogin dev flag
Approved brainstorming design: a config flag that disables AdminUI login,
auto-authenticating every request as 'multi-role-test' with all roles via an
always-succeeding AuthenticationHandler registered under the cookie scheme name.
Default off; enabled in docker-dev (central-1/central-2). AdminUI cookie surface
only; OPC UA LDAP + deploy API key untouched.
2026-06-11 04:19:43 -04:00

9.8 KiB

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) → ILdapAuthServiceIGroupRoleMapper<string> → 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.

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:

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.

// appsettings.json (default)
"Security": { "Auth": { "DisableLogin": false } }

2. Handler — AutoLoginAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>

HandleAuthenticateAsync builds:

var claims = new List<Claim> {
    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

services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
var disableLogin = configuration.GetSection(AuthDisableLoginOptions.SectionName)
                                .GetValue<bool>(nameof(AuthDisableLoginOptions.DisableLogin));

var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme);
if (disableLogin)
{
    // LOUD warning — see Guardrails.
    authBuilder.AddScheme<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
        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.ymlcentral-1 (line ~139) and central-2 (line ~179), the only OTOPCUA_ROLES=admin,driver services:

      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.