Files
mxaccessgw/docs/plans/2026-06-16-dashboard-disable-login.md
T

448 lines
21 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.
# Dashboard "Disable Login" Dev Flag — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Add a `MxGateway:Dashboard:DisableLogin` config flag that, when on, auto-authenticates every dashboard request as a fixed dev user (default `multi-role`) holding both dashboard roles — no login form, cookie, or LDAP bind.
**Architecture:** When the flag is on, the dashboard's `AddCookie(...)` registration is replaced by a custom `AuthenticationHandler` registered **under the same scheme name** (`MxGateway.Dashboard`) whose `HandleAuthenticateAsync` always succeeds with a multi-role principal. `UseAuthentication()` stamps that principal on `HttpContext.User` for every request, so every policy (Viewer/Admin/HubClients), the Blazor circuit, and the SignalR hubs see a signed-in admin with **zero policy or page changes**. Mirrors the sister project OtOpcUa's `Security:Auth:DisableLogin`.
**Tech Stack:** .NET 10 (x64) gateway server; ASP.NET Core authentication/authorization; xUnit. Server-side only — no worker, no `.proto`, no clients, no gRPC API-key changes. Builds and tests entirely on macOS.
**Design doc:** `docs/plans/2026-06-16-dashboard-disable-login-design.md`
**Key existing files (verified):**
- `src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs` — options bound from `MxGateway:Dashboard`.
- `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs::AddGatewayDashboard` — auth scheme + policy wiring.
- `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticationDefaults.cs` — scheme/policy name constants (`AuthenticationScheme = "MxGateway.Dashboard"`, `AdminPolicy`, `ViewerPolicy`).
- `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardRoles.cs``Admin = "Administrator"`, `Viewer = "Viewer"`.
- `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs::CreatePrincipal` — the claim shape to mirror (`ZbClaimTypes.Name/Username/DisplayName` + `ZbClaimTypes.Role` per role; identity authType = scheme, nameType = `ZbClaimTypes.Name`, roleType = `ZbClaimTypes.Role`).
- `ZbClaimTypes` (from `ZB.MOM.WW.Auth.AspNetCore`): `Name` (= `ClaimTypes.Name`), `Role` (= `ClaimTypes.Role`), `Username` (`"zb:username"`), `DisplayName` (`"zb:displayname"`).
- `src/ZB.MOM.WW.MxGateway.Server/Properties/AssemblyInfo.cs``InternalsVisibleTo("ZB.MOM.WW.MxGateway.Tests")` (so `internal` members are test-visible).
**Test conventions (verified):** no Moq/NSubstitute — hand-written stubs only. Integration-style tests build the real app with `GatewayApplication.Build(["--MxGateway:Dashboard:Key=value"])` and resolve services from `app.Services` (see `DashboardCookieOptionsTests`, `DashboardHubsRegistrationTests`). Run filtered tests only (per standing guidance), with `MSBUILDDISABLENODEREUSE=1`.
---
### Task 1: Config fields on `DashboardOptions`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (Tasks 2/3 depend on these fields)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs`
**Step 1: Write the failing test** — add to `GatewayOptionsTests.cs`:
```csharp
[Fact]
public void DashboardOptions_DisableLogin_DefaultsToFalse()
{
Assert.False(new DashboardOptions().DisableLogin);
}
[Fact]
public void DashboardOptions_AutoLoginUser_DefaultsToNull()
{
Assert.Null(new DashboardOptions().AutoLoginUser);
}
```
(If `GatewayOptionsTests` lacks `using ZB.MOM.WW.MxGateway.Server.Configuration;`, add it.)
**Step 2: Run it, expect FAIL** (compile error: no such members)
Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~GatewayOptionsTests.DashboardOptions"`
**Step 3: Add the two `init` properties** to `DashboardOptions.cs` (place near `AllowAnonymousLocalhost`):
```csharp
/// <summary>
/// DEV/TEST ONLY. When true, the dashboard bypasses the login form entirely and
/// auto-authenticates EVERY request as <see cref="AutoLoginUser"/> holding both
/// dashboard roles (Administrator + Viewer). No cookie, no LDAP bind. Default false.
/// Unlike <see cref="AllowAnonymousLocalhost"/> (which only succeeds the authorization
/// requirement without authenticating), this mints a real principal, so the UI behaves
/// as a signed-in admin and applies to all clients (not just loopback). Never enable in
/// production. See docs/plans/2026-06-16-dashboard-disable-login-design.md.
/// </summary>
public bool DisableLogin { get; init; }
/// <summary>
/// Username minted for the auto-login principal when <see cref="DisableLogin"/> is true.
/// Null/blank falls back to the GLAuth Administrator test user <c>multi-role</c>.
/// </summary>
public string? AutoLoginUser { get; init; }
```
**Step 4: Run the test, expect PASS.**
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Configuration/DashboardOptions.cs src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsTests.cs
git commit -m "feat(dashboard): add DisableLogin + AutoLoginUser options (default off)"
```
---
### Task 2: `DashboardAutoLoginAuthenticationHandler` + unit tests
**Classification:** high-risk (security/auth code)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 4
**Files:**
- Create: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAutoLoginAuthenticationHandler.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAutoLoginAuthenticationHandlerTests.cs`
**Step 1: Write the failing test** (`DashboardAutoLoginAuthenticationHandlerTests.cs`):
```csharp
using System.Security.Claims;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardAutoLoginAuthenticationHandlerTests
{
[Fact]
public void CreatePrincipal_MintsAuthenticatedMultiRoleUser()
{
ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal("multi-role");
Assert.True(principal.Identity!.IsAuthenticated);
Assert.Equal("multi-role", principal.Identity!.Name);
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void CreatePrincipal_BlankUser_FallsBackToDefault(string? user)
{
ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal(user);
Assert.Equal(DashboardAutoLoginAuthenticationHandler.DefaultUser, principal.Identity!.Name);
}
[Fact]
public void CreatePrincipal_TrimsUser()
{
ClaimsPrincipal principal = DashboardAutoLoginAuthenticationHandler.CreatePrincipal(" multi-role ");
Assert.Equal("multi-role", principal.Identity!.Name);
}
}
```
**Step 2: Run it, expect FAIL** (type does not exist).
Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~DashboardAutoLoginAuthenticationHandlerTests"`
**Step 3: Implement** `DashboardAutoLoginAuthenticationHandler.cs`:
```csharp
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authentication handler used ONLY when <c>MxGateway:Dashboard:DisableLogin</c> is true.
/// Registered under the dashboard cookie scheme name
/// (<see cref="DashboardAuthenticationDefaults.AuthenticationScheme"/>), it authenticates
/// EVERY request as the configured dev user with both dashboard roles — no credential check,
/// no cookie, no LDAP bind. The minted principal mirrors the shape the real login
/// (<see cref="DashboardAuthenticator"/>) produces, so policies and the UI cannot tell it
/// apart. DEV/TEST ONLY; never enable in production.
/// </summary>
public sealed class DashboardAutoLoginAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>, IAuthenticationSignInHandler
{
/// <summary>Username used when <c>AutoLoginUser</c> is null or blank.</summary>
public const string DefaultUser = "multi-role";
private readonly string _user;
/// <summary>Initializes the handler with scheme plumbing and the dashboard options.</summary>
/// <param name="options">The per-scheme authentication options monitor.</param>
/// <param name="logger">The logger factory the base handler uses.</param>
/// <param name="encoder">The URL encoder the base handler uses.</param>
/// <param name="gatewayOptions">Gateway options carrying the dashboard auto-login user.</param>
public DashboardAutoLoginAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<GatewayOptions> gatewayOptions)
: base(options, logger, encoder)
=> _user = gatewayOptions.Value.Dashboard.AutoLoginUser ?? DefaultUser;
/// <summary>No-op: auto-login writes no cookie, so a sign-in has nothing to persist.</summary>
/// <param name="user">Ignored.</param>
/// <param name="properties">Ignored.</param>
/// <returns>A completed task.</returns>
public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask;
/// <summary>No-op: there is no auth cookie to clear; the next request re-authenticates.</summary>
/// <param name="properties">Ignored.</param>
/// <returns>A completed task.</returns>
public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask;
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
ClaimsPrincipal principal = CreatePrincipal(_user);
AuthenticationTicket ticket = new(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
/// <summary>
/// Builds the multi-role dev principal. Null/blank <paramref name="user"/> falls back to
/// <see cref="DefaultUser"/>. Claim shape mirrors <see cref="DashboardAuthenticator"/>.
/// </summary>
/// <param name="user">The configured auto-login username (may be null/blank).</param>
/// <returns>An authenticated principal holding both dashboard roles.</returns>
internal static ClaimsPrincipal CreatePrincipal(string? user)
{
string name = string.IsNullOrWhiteSpace(user) ? DefaultUser : user.Trim();
Claim[] claims =
[
new Claim(ClaimTypes.NameIdentifier, name),
new Claim(ZbClaimTypes.Username, name),
new Claim(ZbClaimTypes.Name, name),
new Claim(ZbClaimTypes.DisplayName, name),
new Claim(ZbClaimTypes.Role, DashboardRoles.Admin),
new Claim(ZbClaimTypes.Role, DashboardRoles.Viewer),
];
ClaimsIdentity identity = new(
claims,
DashboardAuthenticationDefaults.AuthenticationScheme,
ZbClaimTypes.Name,
ZbClaimTypes.Role);
return new ClaimsPrincipal(identity);
}
}
```
**Step 4: Run the test, expect PASS.**
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAutoLoginAuthenticationHandler.cs src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardAutoLoginAuthenticationHandlerTests.cs
git commit -m "feat(dashboard): add auto-login auth handler for DisableLogin mode"
```
---
### Task 3: Wire the scheme swap + startup warning + wiring/authorization tests
**Classification:** high-risk (security wiring)
**Estimated implement time:** ~5 min
**Parallelizable with:** none (depends on Task 2's handler)
**Files:**
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs`
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardDisableLoginTests.cs` (create)
**Step 1: Write the failing tests** (`DashboardDisableLoginTests.cs`):
```csharp
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
public sealed class DashboardDisableLoginTests
{
[Fact]
public async Task DisableLoginOff_CookieSchemeUsesCookieHandler()
{
await using WebApplication app = GatewayApplication.Build([]);
IAuthenticationSchemeProvider provider =
app.Services.GetRequiredService<IAuthenticationSchemeProvider>();
AuthenticationScheme? scheme = await provider.GetSchemeAsync(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(scheme);
Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType);
}
[Fact]
public async Task DisableLoginOn_CookieSchemeUsesAutoLoginHandler()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:DisableLogin=true"]);
IAuthenticationSchemeProvider provider =
app.Services.GetRequiredService<IAuthenticationSchemeProvider>();
AuthenticationScheme? scheme = await provider.GetSchemeAsync(
DashboardAuthenticationDefaults.AuthenticationScheme);
Assert.NotNull(scheme);
Assert.Equal(typeof(DashboardAutoLoginAuthenticationHandler), scheme!.HandlerType);
}
[Fact]
public async Task DisableLoginOn_AutoLoginPrincipalSatisfiesAdminAndViewerPolicies()
{
await using WebApplication app = GatewayApplication.Build(
["--MxGateway:Dashboard:DisableLogin=true"]);
IAuthorizationService authorization =
app.Services.GetRequiredService<IAuthorizationService>();
ClaimsPrincipal user = DashboardAutoLoginAuthenticationHandler.CreatePrincipal("multi-role");
Assert.True((await authorization.AuthorizeAsync(
user, resource: null, DashboardAuthenticationDefaults.AdminPolicy)).Succeeded);
Assert.True((await authorization.AuthorizeAsync(
user, resource: null, DashboardAuthenticationDefaults.ViewerPolicy)).Succeeded);
}
}
```
> Note: `AuthorizeAsync` invokes the real `DashboardAuthorizationHandler` against the minted
> principal — its role-check branch succeeds independent of `HttpContext` (loopback check
> returns false with no request, and `Authentication.Mode` defaults to `ApiKey`), so this
> proves the policies pass purely on the minted roles.
**Step 2: Run them, expect FAIL** (the `DisableLoginOn_*` tests fail — handler not yet wired; cookie handler still registered).
Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~DashboardDisableLoginTests"`
**Step 3: Rewire `AddGatewayDashboard`.** In `DashboardServiceCollectionExtensions.cs`, replace the current authentication-builder block:
```csharp
services
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
{
// ... existing cookie config ...
})
.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
```
with:
```csharp
// DEV/TEST ONLY. Read directly from configuration here because authentication scheme
// registration runs before options binding. Key mirrors DashboardOptions.DisableLogin.
bool disableLogin = configuration.GetValue<bool>("MxGateway:Dashboard:DisableLogin");
AuthenticationBuilder authentication =
services.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme);
if (disableLogin)
{
// Register an always-authenticating handler UNDER the cookie scheme name, so the
// Viewer/Admin/HubClients policies (which all resolve this scheme) authenticate
// through it as the multi-role dev user — zero policy or page changes.
authentication.AddScheme<AuthenticationSchemeOptions, DashboardAutoLoginAuthenticationHandler>(
DashboardAuthenticationDefaults.AuthenticationScheme,
_ => { });
// Loud, once-at-startup warning (emitted when GatewayOptions is first resolved).
services.AddOptions<GatewayOptions>().PostConfigure<ILoggerFactory>((gatewayOptions, loggerFactory) =>
loggerFactory
.CreateLogger("ZB.MOM.WW.MxGateway.Server.Dashboard.DisableLogin")
.LogWarning(
"DASHBOARD LOGIN DISABLED (MxGateway:Dashboard:DisableLogin=true) — every request is "
+ "authenticated as '{User}' with full permissions ({Roles}). Dev/test only; never "
+ "enable in production.",
gatewayOptions.Dashboard.AutoLoginUser ?? DashboardAutoLoginAuthenticationHandler.DefaultUser,
$"{DashboardRoles.Admin}, {DashboardRoles.Viewer}"));
}
else
{
authentication.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
{
// ... MOVE the existing cookie config body here unchanged ...
});
}
authentication.AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
DashboardAuthenticationDefaults.HubAuthenticationScheme,
_ => { });
```
Notes for the implementer:
- Keep the existing `services.AddOptions<CookieAuthenticationOptions>(scheme).Configure(...)` block (RequireHttpsCookie / cookie-name) as-is. When `disableLogin` is on it configures an options object no handler reads — harmless dead config; not worth guarding.
- Required usings should already be present (`Microsoft.AspNetCore.Authentication`, `Microsoft.Extensions.Configuration`, `Microsoft.Extensions.Logging`, the `Configuration` namespace for `GatewayOptions`). Add any that are missing.
- `configuration.GetValue<bool>` defaults to `false` when the key is absent — preserves default-off.
**Step 4: Run the tests, expect PASS** (all three).
**Step 5: Run the broader dashboard auth tests to confirm no regression:**
Run: `MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter "FullyQualifiedName~Dashboard"`
Expected: all pass (existing `DashboardCookieOptionsTests`, `DashboardHubsRegistrationTests`, etc., still green — they build with the flag off).
> The startup warning is verified by inspection / manual run (`dotnet run … --MxGateway:Dashboard:DisableLogin=true` logs the warning once). It is not asserted automatically — capturing a startup log line would require injecting a log provider the `Build` harness does not expose, and the warning is a safety nicety, not core behavior.
**Step 6: Commit**
```bash
git add src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardDisableLoginTests.cs
git commit -m "feat(dashboard): swap to auto-login handler when DisableLogin is set"
```
---
### Task 4: Documentation
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** Task 2 (disjoint files — docs vs src/test)
**Files:**
- Modify: `docs/GatewayConfiguration.md`
- Modify: `docs/GatewayDashboardDesign.md`
- Modify: `CLAUDE.md`
**Step 1:** In `docs/GatewayConfiguration.md`, add `MxGateway:Dashboard:DisableLogin` (bool, default `false`) and `MxGateway:Dashboard:AutoLoginUser` (string, default `multi-role`) to the dashboard options section. Describe: dev/test only; auto-authenticates every request as `AutoLoginUser` with both roles; applies to all clients (not just loopback); never enable in production. Note it differs from `AllowAnonymousLocalhost` (which only bypasses authorization without minting a principal).
**Step 2:** In `docs/GatewayDashboardDesign.md`, document the auth-scheme swap: when the flag is on, the cookie handler is replaced by `DashboardAutoLoginAuthenticationHandler` under the same scheme name; explain *why* (every policy resolves that scheme, so no policy/page changes), and that it is dev/test only with a loud startup warning.
**Step 3:** In `CLAUDE.md`, in the Authentication section near the `Dashboard:AllowAnonymousLocalhost` sentence, add one sentence: `MxGateway:Dashboard:DisableLogin` (default off) auto-authenticates every dashboard request as `AutoLoginUser` (default `multi-role`) with all roles — dev/test only.
**Step 4: Commit**
```bash
git add docs/GatewayConfiguration.md docs/GatewayDashboardDesign.md CLAUDE.md
git commit -m "docs: document dashboard DisableLogin / AutoLoginUser dev flag"
```
---
## Verification (after all tasks)
```bash
MSBUILDDISABLENODEREUSE=1 dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj \
--filter "FullyQualifiedName~Dashboard|FullyQualifiedName~GatewayOptions"
```
Expected: all dashboard + options tests pass. (Known macOS-only failures `OrphanWorkerTerminatorTests` ×2 and the parallel-load `SqliteAuthStoreTests` TLS temp-file test are unrelated and out of this filter.)
Then `superpowers-extended-cc:finishing-a-development-branch` to merge/push.