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.
This commit is contained in:
@@ -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<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`.
|
||||
|
||||
## 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<AuthenticationSchemeOptions>`
|
||||
|
||||
`HandleAuthenticateAsync` builds:
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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.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`.
|
||||
Reference in New Issue
Block a user