From bc31b6a4de7a4e0fbbaee3c54b453d3aeec41006 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 11 Jun 2026 04:24:00 -0400 Subject: [PATCH] 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. --- .../plans/2026-06-11-adminui-disable-login.md | 417 ++++++++++++++++++ ...-06-11-adminui-disable-login.md.tasks.json | 18 + 2 files changed, 435 insertions(+) create mode 100644 docs/plans/2026-06-11-adminui-disable-login.md create mode 100644 docs/plans/2026-06-11-adminui-disable-login.md.tasks.json diff --git a/docs/plans/2026-06-11-adminui-disable-login.md b/docs/plans/2026-06-11-adminui-disable-login.md new file mode 100644 index 00000000..bef506c0 --- /dev/null +++ b/docs/plans/2026-06-11-adminui-disable-login.md @@ -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 **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. diff --git a/docs/plans/2026-06-11-adminui-disable-login.md.tasks.json b/docs/plans/2026-06-11-adminui-disable-login.md.tasks.json new file mode 100644 index 00000000..7c088fea --- /dev/null +++ b/docs/plans/2026-06-11-adminui-disable-login.md.tasks.json @@ -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" +}