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

21 KiB
Raw Blame History

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.csAdmin = "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.csInternalsVisibleTo("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:

[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):

/// <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

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):

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:

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

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):

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:

services
    .AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme, cookieOptions =>
    {
        // ... existing cookie config ...
    })
    .AddScheme<AuthenticationSchemeOptions, HubTokenAuthenticationHandler>(
        DashboardAuthenticationDefaults.HubAuthenticationScheme,
        _ => { });

with:

// 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

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

git add docs/GatewayConfiguration.md docs/GatewayDashboardDesign.md CLAUDE.md
git commit -m "docs: document dashboard DisableLogin / AutoLoginUser dev flag"

Verification (after all tasks)

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.