Files
lmxopcua/docs/plans/2026-06-11-adminui-disable-login.md
T
Joseph Doherty bc31b6a4de 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.
2026-06-11 04:24:00 -04:00

418 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.