docs(adminui): implementation plan for Security:Auth:DisableLogin dev flag

6-task plan (T0 branch -> T1 options/roles -> T2 handler -> T3 wiring -> T5 verify;
T4 config+docker-dev parallel). AutoLoginAuthenticationHandler registered under the
cookie scheme name so existing policies keep working; enabled in docker-dev.
This commit is contained in:
Joseph Doherty
2026-06-11 04:24:00 -04:00
parent 789176738f
commit bc31b6a4de
2 changed files with 435 additions and 0 deletions
@@ -0,0 +1,417 @@
# 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 **7282**; the options binds are at **3638**; the policy block
(`FallbackPolicy`, `DriverOperator`, `FleetAdmin`) is **113131** 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.
@@ -0,0 +1,18 @@
{
"planPath": "docs/plans/2026-06-11-adminui-disable-login.md",
"designPath": "docs/plans/2026-06-11-adminui-disable-login-design.md",
"branch": "feat/adminui-disable-login",
"baseBranch": "master",
"baseSha": "78917673",
"status": "pending",
"note": "Security:Auth:DisableLogin flag — auto-authenticate AdminUI as multi-role-test with all roles via an AutoLoginAuthenticationHandler registered UNDER the cookie scheme name (so FallbackPolicy + FleetAdmin + DriverOperator keep working unchanged). Enabled in docker-dev central-1/central-2. AdminUI cookie surface only.",
"tasks": [
{"id": 229, "planTask": 0, "subject": "DL-T0: Branch + baseline", "classification": "small", "status": "pending", "blockedBy": []},
{"id": 230, "planTask": 1, "subject": "DL-T1: AuthDisableLoginOptions + centralized role list", "classification": "small", "status": "pending", "blockedBy": [229]},
{"id": 231, "planTask": 2, "subject": "DL-T2: AutoLoginAuthenticationHandler", "classification": "high-risk", "status": "pending", "blockedBy": [230]},
{"id": 232, "planTask": 3, "subject": "DL-T3: Branch AddOtOpcUaAuth on the flag (+ loud warning)", "classification": "high-risk", "status": "pending", "blockedBy": [230, 231]},
{"id": 233, "planTask": 4, "subject": "DL-T4: appsettings default + docker-dev enablement", "classification": "small", "status": "pending", "blockedBy": [230], "parallelizableWith": [231, 232]},
{"id": 234, "planTask": 5, "subject": "DL-T5: Live-verify (docker-dev /run)", "classification": "verification", "status": "pending", "blockedBy": [232, 233]}
],
"lastUpdated": "2026-06-11"
}