# AdminUI "Disable Login" Dev Flag — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development > (or executing-plans) to implement this plan task-by-task. **Goal:** Add a `Security:Auth:DisableLogin` config flag that disables AdminUI login — when on, every request is auto-authenticated as `multi-role-test` with all roles — and enable it in docker-dev. **Architecture:** A custom `AuthenticationHandler` registered **under the cookie scheme name** when the flag is on (replacing `AddCookie`); it always returns `AuthenticateResult.Success` with an all-roles principal. Because the `FallbackPolicy` + `FleetAdmin` + `DriverOperator` policies all *name the cookie scheme*, they keep working unchanged. `HttpContext.User` is the single source feeding both the HTTP pipeline and the Blazor circuit. Design: `docs/plans/2026-06-11-adminui-disable-login-design.md` (master `78917673`). **Tech Stack:** .NET 10, ASP.NET Core cookie/authentication, Blazor Server, xUnit + Shouldly. No bUnit. **Hard rules (every task):** stage by explicit path — never `git add .`; never stage `sql_login.txt` or `src/Server/ZB.MOM.WW.OtOpcUa.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.** Agent does **not** sign in to the AdminUI — when the flag is on, no sign-in is needed (that's the point). **Branch:** `feat/adminui-disable-login` off `master @ 78917673`. **Verified context (from the brainstorming exploration — do not re-discover):** - Auth is wired in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` → `AddOtOpcUaAuth` (the **only** `AddAuthentication`/`AddAuthorization` site). Cookie registration is at lines **72–82**; the options binds are at **36–38**; the policy block (`FallbackPolicy`, `DriverOperator`, `FleetAdmin`) is **113–131** and **must stay unchanged**. - Claim type helpers: **`ZbClaimTypes`** (`ZB.MOM.WW.Auth.Abstractions` — same import `AuthEndpoints.cs` uses) — `Name` (== `ClaimTypes.Name`), `Username`, `DisplayName`, `Role` (== `ClaimTypes.Role`). Mirror the principal shape `AuthEndpoints.cs:118-132` builds. - Roles: enum **`AdminRole`** (`src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs`) = `Viewer`, `Designer`, `Administrator`; plus the appsettings-only control-plane string **`Operator`** (the `DriverOperator` policy accepts `Operator` or `Administrator`). - Tests live in `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` (xUnit + Shouldly), e.g. `OtOpcUaLdapAuthServiceTests.cs`, `CanonicalAdminRolesTests.cs`. --- ### Task 0: Branch + baseline **Classification:** small · **~2 min** · **Parallelizable with:** none **Files:** none (branch + verify only) **Steps:** 1. `git switch -c feat/adminui-disable-login` (off `master @ 78917673`). 2. `dotnet build ZB.MOM.WW.OtOpcUa.slnx` — green baseline. Confirm `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/` is in the `.slnx`. 3. Commit nothing. --- ### Task 1: `AuthDisableLoginOptions` + centralized role list **Classification:** small · **~4 min** · **Parallelizable with:** none **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AuthDisableLoginOptions.cs` - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/DevAuthRoles.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/DevAuthRolesTests.cs` **Step 1 — failing test** (`DevAuthRolesTests`): - `DevAuthRoles.All` contains **every** `AdminRole` enum name (`Viewer`, `Designer`, `Administrator`) **and** `"Operator"`; count == 4; no duplicates. - (Guards the "grant all roles" contract against a future `AdminRole` addition.) ```csharp using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Security.Auth; namespace ZB.MOM.WW.OtOpcUa.Security.Tests; public class DevAuthRolesTests { [Fact] public void All_covers_every_AdminRole_plus_Operator() { foreach (var name in Enum.GetNames()) DevAuthRoles.All.ShouldContain(name); DevAuthRoles.All.ShouldContain("Operator"); DevAuthRoles.All.Length.ShouldBe(Enum.GetNames().Length + 1); DevAuthRoles.All.Distinct().Count().ShouldBe(DevAuthRoles.All.Length); } } ``` **Step 2 — run, expect fail** (types don't exist). **Step 3 — implement:** ```csharp // AuthDisableLoginOptions.cs namespace ZB.MOM.WW.OtOpcUa.Security.Auth; /// /// Dev/test flag: when is true the AdminUI bypasses the login /// form entirely and auto-authenticates every request as with all roles. /// Default OFF. Never enable in production. /// public sealed class AuthDisableLoginOptions { /// Configuration section name (Security:Auth). public const string SectionName = "Security:Auth"; /// When true, disable login and auto-authenticate every request. Default false. public bool DisableLogin { get; set; } /// The username the auto-login principal is minted with. Default "multi-role-test". public string User { get; set; } = "multi-role-test"; } ``` ```csharp // DevAuthRoles.cs using ZB.MOM.WW.OtOpcUa.Configuration.Enums; namespace ZB.MOM.WW.OtOpcUa.Security.Auth; /// /// The full canonical role set granted to the auto-login dev principal: every /// plus the appsettings-only control-plane role "Operator" /// (required by the DriverOperator policy). Centralised so adding an AdminRole /// automatically widens the grant. /// public static class DevAuthRoles { /// Operator role string — not an enum member; used by the DriverOperator policy. public const string Operator = "Operator"; /// All roles granted to the auto-login principal. public static readonly string[] All = [.. Enum.GetNames(), Operator]; } ``` **Step 4 — run, expect pass. Step 5 — commit** the 3 files by path. > Verify the `ZB.MOM.WW.OtOpcUa.Security` csproj already references the `Configuration` > project (it imports `ZB.MOM.WW.OtOpcUa.Configuration` in `ServiceCollectionExtensions.cs`, > so it does). If `AdminRole` isn't resolvable, that's a plan defect — surface it. --- ### Task 2: `AutoLoginAuthenticationHandler` **Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T1) **Files:** - Create: `src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs` - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AutoLoginAuthenticationHandlerTests.cs` **Step 1 — failing tests** (`AutoLoginAuthenticationHandlerTests`). Drive the handler directly (`InitializeAsync` with a scheme + `DefaultHttpContext`, then `AuthenticateAsync()`), then assert on the resulting principal: - Success: `result.Succeeded == true`; `result.Principal!.Identity!.Name == "multi-role-test"` (default) — and `== "custom"` when `AuthDisableLoginOptions.User = "custom"`. - Principal carries a role claim for **every** `DevAuthRoles.All` value; `principal.IsInRole("Administrator")`, `IsInRole("Operator")`, `IsInRole("Viewer")`, `IsInRole("Designer")` all true. - **Policy satisfaction:** build the real policies and assert the principal passes both — `RequireRole("Administrator")` (FleetAdmin) and `RequireRole("Operator","Administrator")` (DriverOperator) via `IAuthorizationService.AuthorizeAsync`. (Construct an `AuthorizationService` through `new ServiceCollection().AddAuthorization(...)` mirroring the policy block, or assert `ClaimsPrincipal.IsInRole` for each required role — the IsInRole assertion is sufficient and simpler; prefer it.) Handler-construction helper (modern ASP.NET ctor — **no `ISystemClock`**): ```csharp private static AutoLoginAuthenticationHandler CreateHandler(string user = "multi-role-test") { var schemeOpts = new Mock>(); schemeOpts.Setup(o => o.Get(It.IsAny())).Returns(new AuthenticationSchemeOptions()); var disableOpts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user }); var handler = new AutoLoginAuthenticationHandler( schemeOpts.Object, NullLoggerFactory.Instance, UrlEncoder.Default, disableOpts); return handler; } // In each test: // await handler.InitializeAsync( // new AuthenticationScheme(CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)), // new DefaultHttpContext()); // var result = await handler.AuthenticateAsync(); ``` **Step 2 — run, expect fail** (handler doesn't exist). **Step 3 — implement:** ```csharp using System.Security.Claims; using System.Text.Encodings.Web; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions; // ZbClaimTypes (same import AuthEndpoints uses) namespace ZB.MOM.WW.OtOpcUa.Security.Auth; /// /// Auth handler used ONLY when is true. /// Registered under the cookie scheme name, it authenticates EVERY request as the configured /// dev user with all roles — no credential check, no cookie. /// The minted principal mirrors the shape the real login (AuthEndpoints) produces. /// public sealed class AutoLoginAuthenticationHandler : AuthenticationHandler { private readonly AuthDisableLoginOptions _opts; /// Initializes the handler with the scheme plumbing and the disable-login options. public AutoLoginAuthenticationHandler( IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder, IOptions disableLoginOptions) : base(options, logger, encoder) => _opts = disableLoginOptions.Value; /// protected override Task HandleAuthenticateAsync() { var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role-test" : _opts.User; var claims = new List { new(ZbClaimTypes.Name, user), new(ZbClaimTypes.Username, user), new(ZbClaimTypes.DisplayName, user), }; foreach (var role in DevAuthRoles.All) claims.Add(new Claim(ZbClaimTypes.Role, role)); var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme); var principal = new ClaimsPrincipal(identity); var ticket = new AuthenticationTicket(principal, Scheme.Name); return Task.FromResult(AuthenticateResult.Success(ticket)); } } ``` > `ClaimsIdentity(claims, authenticationType)` — passing the auth-type string makes > `Identity.IsAuthenticated == true` and `Identity.Name` resolve from the `ClaimTypes.Name` > claim. Confirm `ZbClaimTypes.Name == ClaimTypes.Name` (it is — that's why `Identity.Name` > works on the real login path). **Step 4 — run targeted tests** (`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests --filter AutoLoginAuthenticationHandler`), expect pass. **Step 5 — commit** by path. --- ### Task 3: Branch `AddOtOpcUaAuth` on the flag (+ loud warning) **Classification:** high-risk · **~5 min** · **Parallelizable with:** none (depends T1, T2) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs` (bind options near :36-38; replace the `AddAuthentication().AddCookie(...)` at :72-82 with the flag branch; **leave :88-111 cookie PostConfigure and :113-131 policy block untouched**). - Test: `tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/AddOtOpcUaAuthWiringTests.cs` **Step 1 — failing wiring tests** (`AddOtOpcUaAuthWiringTests`). Build a `ServiceCollection`, add minimal config, call `AddOtOpcUaAuth`, resolve `IAuthenticationSchemeProvider`, and assert the **handler type** registered for the cookie scheme: - `DisableLogin=true` → the cookie scheme's `HandlerType == typeof(AutoLoginAuthenticationHandler)`. - `DisableLogin=false` (or absent) → `HandlerType == typeof(CookieAuthenticationHandler)`. ```csharp private static async Task CookieHandlerTypeAsync(bool disableLogin) { var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Security:Auth:DisableLogin"] = disableLogin ? "true" : "false", }).Build(); var services = new ServiceCollection(); services.AddLogging(); services.AddSingleton(config); services.AddDbContext(o => o.UseInMemoryDatabase("wiring")); // DataProtection PersistKeysToDbContext needs it services.AddOtOpcUaAuth(config); var sp = services.BuildServiceProvider(); var provider = sp.GetRequiredService(); var scheme = await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme); return scheme!.HandlerType; } ``` (If `AddDbContext`/InMemory isn't already available to the test project, prefer asserting via the registered `AuthenticationSchemeOptions`/`IConfigureOptions` instead — but the `Security.Tests` project already constructs `OtOpcUaConfigDbContext` in-memory elsewhere, so reuse that. If a DI dependency makes `BuildServiceProvider` throw, that's a plan defect — surface it rather than mocking half the graph.) **Step 2 — run, expect fail.** **Step 3 — implement.** Add the bind alongside the existing `AddOptions` calls: ```csharp services.AddOptions().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName)); ``` Replace lines 72-82 with: ```csharp var disableLogin = configuration .GetSection(AuthDisableLoginOptions.SectionName) .GetValue(nameof(AuthDisableLoginOptions.DisableLogin)); var authBuilder = services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme); if (disableLogin) { // DEV/TEST ONLY: replace the cookie handler with an always-succeeding handler registered // UNDER the cookie scheme name, so FallbackPolicy + FleetAdmin + DriverOperator (which all // name this scheme) authenticate through it and pass with all roles — zero policy changes. authBuilder.AddScheme( CookieAuthenticationDefaults.AuthenticationScheme, _ => { }); // Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom). services.AddOptions() .PostConfigure((opts, lf) => lf.CreateLogger("ZB.MOM.WW.OtOpcUa.Security").LogWarning( "AdminUI LOGIN DISABLED (Security:Auth:DisableLogin=true) — every request is " + "authenticated as '{User}' with FULL permissions ({Roles}). Dev/test only; never " + "enable in production.", opts.User, string.Join(",", DevAuthRoles.All))); } else { authBuilder.AddCookie(o => { o.LoginPath = "/login"; o.LogoutPath = "/auth/logout"; }); } ``` Add `using ZB.MOM.WW.OtOpcUa.Security.Auth;` and `using Microsoft.AspNetCore.Authentication;`. > The cookie-options `PostConfigure` (`AddOptions(...)`, :88-111) > stays — it's keyed to the cookie scheme name and is harmless/ignored when the auto-login > handler backs that scheme. Do NOT delete it. DataProtection, LDAP, JWT, and the > `AddAuthorization` policy block all stay exactly as-is. **Step 4 — run** (`dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests`), expect pass + no regressions in the existing auth tests. **Step 5 — commit** by path. --- ### Task 4: appsettings default + docker-dev enablement **Classification:** small · **~3 min** · **Parallelizable with:** Task 2, Task 3 (disjoint files; depends T1 for the section name) **Files:** - Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` (add the default-off key under `Security`). - Modify: `docker-dev/docker-compose.yml` (enable on `central-1` ~:139 and `central-2` ~:179). **Steps:** 1. **appsettings.json** — add `"Auth": { "DisableLogin": false }` inside the existing `"Security"` object (verify the section exists; merge, don't duplicate). This documents the key and keeps prod default OFF. 2. **docker-compose.yml** — `git status docker-dev/docker-compose.yml` first (it may carry unrelated working-tree edits — leave those alone). With a **targeted `Edit`** (NOT `git add .`), add to the `environment:` block of **both** AdminUI nodes, next to the other `Security__*` keys: ```yaml Security__Auth__DisableLogin: "true" ``` - `central-1` block (~:139, the `&otopcua-host` anchor). - `central-2` block (~:179, it has its own full `environment:` list — add there too). - Do **not** touch the site-* nodes (driver-only, no UI). Do **not** alter the `GALAXY_MXGW_API_KEY` line. 3. **Commit** `src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.json` and `docker-dev/docker-compose.yml` **by explicit path**. If the compose file had unrelated pre-existing edits you did not make, stage **only** your hunk is not possible per-line here — instead surface the pre-existing diff to the user and ask before committing the whole file (do not silently bundle someone else's change). > No test (config only). Proven by Task 5's live `/run`. --- ### Task 5: Live-verify (docker-dev `/run`) **Classification:** verification · **Parallelizable with:** none (depends T3, T4) **Steps:** 1. Rebuild docker-dev so central-1/central-2 pick up the new image + the `Security__Auth__DisableLogin=true` env: `docker compose -f docker-dev/docker-compose.yml up -d --build` (user drives if a sign-in to anything external is needed — but the AdminUI itself needs none now). 2. Browse `http://localhost:9200/` — confirm it loads **straight into the app** (no redirect to `/login`) as **`multi-role-test`**: the Account page shows that user with all roles; a `FleetAdmin`-gated page (e.g. **RoleGrants**) renders; a `DriverOperator` action (DriverStatusPanel Reconnect/Restart) is enabled. 3. Confirm the **loud warning** is in the central node logs at startup. 4. **Agent does not sign in** (none required). Record outcome. Any defect → new fix task. 5. After green: run superpowers-extended-cc:finishing-a-development-branch (full `dotnet test`, then merge to master). Note: leave `Security__Auth__DisableLogin=true` in docker-dev (that's the requested dev state). --- ## Execution notes - **Serial spine:** T0 → T1 → T2 → T3 → T5. **T4 ∥ T2/T3** (disjoint files; only needs the section name from T1). - **One writer:** only T3 touches `ServiceCollectionExtensions.cs`; T1/T2 add new files; T4 touches config files. No shared-file contention. - **Security classification:** T2 + T3 are `high-risk` (they mint an auth principal and rewire the scheme registration) → serial spec→code review. T1/T4 are `small`. - **Checkpoint:** after T3 the flag works in-process (proven by unit tests); T4+T5 land it in docker-dev. Natural pause after T3 before the live run.