# 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<AdminRole>())
            DevAuthRoles.All.ShouldContain(name);
        DevAuthRoles.All.ShouldContain("Operator");
        DevAuthRoles.All.Length.ShouldBe(Enum.GetNames<AdminRole>().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;

/// <summary>
///     Dev/test flag: when <see cref="DisableLogin"/> is true the AdminUI bypasses the login
///     form entirely and auto-authenticates every request as <see cref="User"/> with all roles.
///     Default OFF. Never enable in production.
/// </summary>
public sealed class AuthDisableLoginOptions
{
    /// <summary>Configuration section name (<c>Security:Auth</c>).</summary>
    public const string SectionName = "Security:Auth";

    /// <summary>When true, disable login and auto-authenticate every request. Default false.</summary>
    public bool DisableLogin { get; set; }

    /// <summary>The username the auto-login principal is minted with. Default "multi-role-test".</summary>
    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;

/// <summary>
///     The full canonical role set granted to the auto-login dev principal: every
///     <see cref="AdminRole"/> plus the appsettings-only control-plane role "Operator"
///     (required by the DriverOperator policy). Centralised so adding an AdminRole
///     automatically widens the grant.
/// </summary>
public static class DevAuthRoles
{
    /// <summary>Operator role string — not an <see cref="AdminRole"/> enum member; used by the DriverOperator policy.</summary>
    public const string Operator = "Operator";

    /// <summary>All roles granted to the auto-login principal.</summary>
    public static readonly string[] All =
        [.. Enum.GetNames<AdminRole>(), 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<IOptionsMonitor<AuthenticationSchemeOptions>>();
    schemeOpts.Setup(o => o.Get(It.IsAny<string>())).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;

/// <summary>
///     Auth handler used ONLY when <see cref="AuthDisableLoginOptions.DisableLogin"/> is true.
///     Registered under the cookie scheme name, it authenticates EVERY request as the configured
///     dev user with all <see cref="DevAuthRoles.All"/> roles — no credential check, no cookie.
///     The minted principal mirrors the shape the real login (AuthEndpoints) produces.
/// </summary>
public sealed class AutoLoginAuthenticationHandler
    : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private readonly AuthDisableLoginOptions _opts;

    /// <summary>Initializes the handler with the scheme plumbing and the disable-login options.</summary>
    public AutoLoginAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory logger,
        UrlEncoder encoder,
        IOptions<AuthDisableLoginOptions> disableLoginOptions)
        : base(options, logger, encoder)
        => _opts = disableLoginOptions.Value;

    /// <inheritdoc />
    protected override Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role-test" : _opts.User;

        var claims = new List<Claim>
        {
            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<Type> CookieHandlerTypeAsync(bool disableLogin)
{
    var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
    {
        ["Security:Auth:DisableLogin"] = disableLogin ? "true" : "false",
    }).Build();

    var services = new ServiceCollection();
    services.AddLogging();
    services.AddSingleton<IConfiguration>(config);
    services.AddDbContext<OtOpcUaConfigDbContext>(o => o.UseInMemoryDatabase("wiring"));  // DataProtection PersistKeysToDbContext needs it
    services.AddOtOpcUaAuth(config);

    var sp = services.BuildServiceProvider();
    var provider = sp.GetRequiredService<IAuthenticationSchemeProvider>();
    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<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));
```
Replace lines 72-82 with:
```csharp
var disableLogin = configuration
    .GetSection(AuthDisableLoginOptions.SectionName)
    .GetValue<bool>(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<AuthenticationSchemeOptions, AutoLoginAuthenticationHandler>(
        CookieAuthenticationDefaults.AuthenticationScheme, _ => { });

    // Loud, once-at-first-resolve warning (mirrors the cookie RequireHttps warning idiom).
    services.AddOptions<AuthDisableLoginOptions>()
        .PostConfigure<ILoggerFactory>((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<CookieAuthenticationOptions>(...)`, :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.
