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

19 KiB
Raw Blame History

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.csAddOtOpcUaAuth (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.)
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:

// 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";
}
// 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):

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:

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

services.AddOptions<AuthDisableLoginOptions>().Bind(configuration.GetSection(AuthDisableLoginOptions.SectionName));

Replace lines 72-82 with:

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.ymlgit 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:
          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.