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.
19 KiB
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 onlyAddAuthentication/AddAuthorizationsite). 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 importAuthEndpoints.csuses) —Name(==ClaimTypes.Name),Username,DisplayName,Role(==ClaimTypes.Role). Mirror the principal shapeAuthEndpoints.cs:118-132builds. - Roles: enum
AdminRole(src/Core/ZB.MOM.WW.OtOpcUa.Configuration/Enums/AdminRole.cs) =Viewer,Designer,Administrator; plus the appsettings-only control-plane stringOperator(theDriverOperatorpolicy acceptsOperatororAdministrator). - 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:
git switch -c feat/adminui-disable-login(offmaster @ 78917673).dotnet build ZB.MOM.WW.OtOpcUa.slnx— green baseline. Confirmtests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/is in the.slnx.- 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.Allcontains everyAdminRoleenum name (Viewer,Designer,Administrator) and"Operator"; count == 4; no duplicates.- (Guards the "grant all roles" contract against a future
AdminRoleaddition.)
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.Securitycsproj already references theConfigurationproject (it importsZB.MOM.WW.OtOpcUa.ConfigurationinServiceCollectionExtensions.cs, so it does). IfAdminRoleisn'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"whenAuthDisableLoginOptions.User = "custom". - Principal carries a role claim for every
DevAuthRoles.Allvalue;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) andRequireRole("Operator","Administrator")(DriverOperator) viaIAuthorizationService.AuthorizeAsync. (Construct anAuthorizationServicethroughnew ServiceCollection().AddAuthorization(...)mirroring the policy block, or assertClaimsPrincipal.IsInRolefor 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 makesIdentity.IsAuthenticated == trueandIdentity.Nameresolve from theClaimTypes.Nameclaim. ConfirmZbClaimTypes.Name == ClaimTypes.Name(it is — that's whyIdentity.Nameworks 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 theAddAuthentication().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'sHandlerType == 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 theAddAuthorizationpolicy 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 underSecurity). - Modify:
docker-dev/docker-compose.yml(enable oncentral-1~:139 andcentral-2~:179).
Steps:
- 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. - docker-compose.yml —
git status docker-dev/docker-compose.ymlfirst (it may carry unrelated working-tree edits — leave those alone). With a targetedEdit(NOTgit add .), add to theenvironment:block of both AdminUI nodes, next to the otherSecurity__*keys:Security__Auth__DisableLogin: "true"central-1block (~:139, the&otopcua-hostanchor).central-2block (~:179, it has its own fullenvironment:list — add there too).- Do not touch the site-* nodes (driver-only, no UI). Do not alter the
GALAXY_MXGW_API_KEYline.
- Commit
src/Server/ZB.MOM.WW.OtOpcUa.Host/appsettings.jsonanddocker-dev/docker-compose.ymlby 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:
- Rebuild docker-dev so central-1/central-2 pick up the new image + the
Security__Auth__DisableLogin=trueenv: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). - Browse
http://localhost:9200/— confirm it loads straight into the app (no redirect to/login) asmulti-role-test: the Account page shows that user with all roles; aFleetAdmin-gated page (e.g. RoleGrants) renders; aDriverOperatoraction (DriverStatusPanel Reconnect/Restart) is enabled. - Confirm the loud warning is in the central node logs at startup.
- Agent does not sign in (none required). Record outcome. Any defect → new fix task.
- After green: run superpowers-extended-cc:finishing-a-development-branch (full
dotnet test, then merge to master). Note: leaveSecurity__Auth__DisableLogin=truein 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 aresmall. - 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.