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.
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 policiesFleetAdmin(→ roleAdministrator) andDriverOperator(→OperatororAdministrator). All three name the cookie scheme explicitly (AuthorizationPolicyBuilder(CookieAuthenticationDefaults.AuthenticationScheme)). - Login flow:
/loginpage →POST /auth/login(Security/Endpoints/AuthEndpoints.cs) →ILdapAuthService→IGroupRoleMapper<string>→ builds aClaimsPrincipal(ZbClaimTypes.Name/Username/DisplayName+ZbClaimTypes.Roleper role) →SignInAsynccookie. - Blazor reads auth via the custom
CookieAuthenticationStateProvider(Security/Blazor/), which snapshots the boot request'sHttpContext.Userand 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/*FleetAdminendpoints) and the Blazor circuit (AuthorizeView,[CascadingParameter] AuthenticationState, policy checks) are covered by one seam. - Roles:
AdminRoleenum =Viewer,Designer,Administrator(Core/.../Configuration/Enums/AdminRole.cs), plus the appsettings-only control-plane stringOperator(used by theDriverOperatorpolicy). Granting all four satisfies every policy and every role-gatedAuthorizeView.
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:
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 asProduction, 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):
/loginis never reached (nothing challenges); manually visiting it still shows the form but is harmless./auth/logoutclears 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:
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:
AutoLoginAuthenticationHandlerreturnsSuccesswithIdentity.Name == "multi-role-test"(or the configuredUser) and role claims for all four canonical roles; the principal satisfiesFleetAdminandDriverOperator(assert viaIAuthorizationService/policy evaluation or by checkingIsInRole). - 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 asmulti-role-test,FleetAdmin-gated pages (e.g. RoleGrants) andDriverOperatoractions 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.