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

430 lines
20 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.
# Dev Disable-Login (Auto-Login) Flag — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. (Or subagent-driven-development if executing in this session.)
**Goal:** Add a dev/test config flag `ScadaBridge:Security:Auth:DisableLogin` that, when true, bypasses login and auto-authenticates every request as the `multi-role` user with all four ScadaBridge roles, system-wide.
**Architecture:** Faithful port of OtOpcUa's mechanism — a custom `AuthenticationHandler` registered **under the cookie scheme name** when the flag is set, so all authorization policies (which name that scheme) authenticate through it with zero policy changes. The minted principal reuses M2.19's `SessionClaimBuilder` for claim parity. No-op sign-in/out (so `/auth/logout` doesn't throw). Loud startup warning; **no environment guard** (per design decision).
**Tech Stack:** C#/.NET 10, ASP.NET Core cookie authentication, xUnit + NSubstitute. Design doc: `docs/plans/2026-06-16-disable-login-design.md`. Branch: `feature/disable-login` (off `main`, M2 merged — so `SessionClaimBuilder` is present).
**Build/test scope:** targeted per-task — build only the affected project(s) (`dotnet build src/<Project>/<Project>.csproj`), run only `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter <Name>`. TreatWarningsAsErrors is ON (0 warnings). One full-solution build (`dotnet build ZB.MOM.WW.ScadaBridge.slnx`) at the very end before declaring done.
**Reference (verbatim) — OtOpcUa handler** at `/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.Security/Auth/AutoLoginAuthenticationHandler.cs`: derives `AuthenticationHandler<AuthenticationSchemeOptions>, IAuthenticationSignInHandler`; ctor `(IOptionsMonitor<AuthenticationSchemeOptions>, ILoggerFactory, UrlEncoder, IOptions<AuthDisableLoginOptions>)`; no-op `SignInAsync`/`SignOutAsync`; `HandleAuthenticateAsync` mints the principal and returns `AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name))`.
---
## Task 1: Options class + `Roles.All`
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs` (add `All` array after line 40)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs` (new)
**Step 1: Create the options class**
```csharp
// src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs
namespace ZB.MOM.WW.ScadaBridge.Security.Auth;
/// <summary>
/// Dev/test flag: when <see cref="DisableLogin"/> is true the Central UI bypasses the login
/// form entirely and auto-authenticates EVERY request as <see cref="User"/> with ALL roles,
/// system-wide. Default OFF. This disables authentication on a SCADA control surface —
/// dev/test ONLY; never enable in production.
/// </summary>
public sealed class AuthDisableLoginOptions
{
/// <summary>Configuration section name (<c>ScadaBridge:Security:Auth</c>).</summary>
public const string SectionName = "ScadaBridge: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".</summary>
public string User { get; set; } = "multi-role";
}
```
**Step 2: Add `Roles.All`** — in `Roles.cs`, after the `Viewer` const (line 40):
```csharp
/// <summary>All declared ScadaBridge roles — the single source of truth for "all
/// permissions" (e.g. the dev auto-login principal). Stays in sync if a role is added.</summary>
public static readonly string[] All = [Administrator, Designer, Deployer, Viewer];
```
**Step 3: Write + run the guard test**
```csharp
// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs
using ZB.MOM.WW.ScadaBridge.Security;
using Xunit;
public class RolesAllTests
{
[Fact]
public void All_ContainsEveryDeclaredRole()
{
Assert.Equal(
new[] { Roles.Administrator, Roles.Designer, Roles.Deployer, Roles.Viewer },
Roles.All);
}
}
```
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~RolesAllTests"` → PASS.
**Step 4: Build + commit**
`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj` (0 warnings), then:
```bash
git add src/ZB.MOM.WW.ScadaBridge.Security/Auth/AuthDisableLoginOptions.cs \
src/ZB.MOM.WW.ScadaBridge.Security/Roles.cs \
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/RolesAllTests.cs
git commit -m "feat(security): AuthDisableLoginOptions + Roles.All for dev auto-login"
```
**Acceptance:** options class exists with `SectionName="ScadaBridge:Security:Auth"`, `User="multi-role"`; `Roles.All` = the four roles; test green.
---
## Task 2: `AutoLoginAuthenticationHandler` + unit tests
**Classification:** high-risk *(security — grants all roles / bypasses auth; warrants the serial spec→code review chain)*
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Depends on:** Task 1
**Files:**
- Create: `src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs` (new)
**Step 1: Implement the handler** (reuses `SessionClaimBuilder` for claim parity; mirrors the OtOpcUa shape):
```csharp
// src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Security.Claims;
namespace ZB.MOM.WW.ScadaBridge.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="Roles.All"/> roles, system-wide — no credential check, no cookie.
/// The minted principal mirrors a real login (it reuses <see cref="SessionClaimBuilder"/>).
/// Dev/test ONLY.
/// </summary>
public sealed class AutoLoginAuthenticationHandler
: AuthenticationHandler<AuthenticationSchemeOptions>, IAuthenticationSignInHandler
{
private readonly AuthDisableLoginOptions _opts;
private readonly TimeProvider _clock;
/// <summary>Initializes the handler with the scheme plumbing, the disable-login options, and the clock.</summary>
public AutoLoginAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IOptions<AuthDisableLoginOptions> disableLoginOptions,
TimeProvider clock)
: base(options, logger, encoder)
{
_opts = disableLoginOptions.Value;
_clock = clock;
}
/// <summary>No-op: auto-login writes no cookie, so an explicit sign-in has nothing to persist.</summary>
public Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? properties) => Task.CompletedTask;
/// <summary>No-op: there is no auth cookie to clear; the next request re-authenticates via this handler.</summary>
public Task SignOutAsync(AuthenticationProperties? properties) => Task.CompletedTask;
/// <inheritdoc />
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var user = string.IsNullOrWhiteSpace(_opts.User) ? "multi-role" : _opts.User;
// All roles, system-wide (no site-scope claims). Reuse the canonical builder so the
// principal is byte-shape-identical to a real all-roles system-wide login.
var mapping = new RoleMappingResult(Roles.All, [], IsSystemWideDeployment: true);
var principal = SessionClaimBuilder.Build(
username: user,
displayName: user,
groups: [],
mapping: mapping,
refreshTimestamp: _clock.GetUtcNow());
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
```
**Step 2: Write the failing tests** (xUnit + the repo's existing patterns; `AuthenticationHandler` needs `InitializeAsync` before `AuthenticateAsync`):
```csharp
// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
using Xunit;
public class AutoLoginAuthenticationHandlerTests
{
private static async Task<AutoLoginAuthenticationHandler> CreateAsync(string user = "multi-role")
{
var schemeOptions = Substitute.For<IOptionsMonitor<AuthenticationSchemeOptions>>();
schemeOptions.Get(Arg.Any<string>()).Returns(new AuthenticationSchemeOptions());
var opts = Options.Create(new AuthDisableLoginOptions { DisableLogin = true, User = user });
var handler = new AutoLoginAuthenticationHandler(
schemeOptions, NullLoggerFactory.Instance, UrlEncoder.Default, opts, TimeProvider.System);
await handler.InitializeAsync(
new AuthenticationScheme(
CookieAuthenticationDefaults.AuthenticationScheme, null, typeof(AutoLoginAuthenticationHandler)),
new DefaultHttpContext());
return handler;
}
[Fact]
public async Task Authenticate_GrantsAllRoles_SystemWide_AsConfiguredUser()
{
var handler = await CreateAsync("multi-role");
var result = await handler.AuthenticateAsync();
Assert.True(result.Succeeded);
var p = result.Principal!;
Assert.Equal("multi-role", p.Identity!.Name);
foreach (var role in Roles.All)
Assert.True(p.IsInRole(role), $"expected role {role}");
// System-wide ⇒ no ScopeId/site claims.
Assert.Empty(p.FindAll(JwtTokenService.SiteIdClaimType));
}
[Fact]
public async Task Authenticate_BlankUser_FallsBackToMultiRole()
{
var handler = await CreateAsync(" ");
var result = await handler.AuthenticateAsync();
Assert.Equal("multi-role", result.Principal!.Identity!.Name);
}
[Fact]
public async Task SignInAndSignOut_AreNoOps_DoNotThrow()
{
var handler = await CreateAsync();
await handler.SignInAsync(new ClaimsPrincipal(), null); // no throw
await handler.SignOutAsync(null); // no throw
}
}
```
Run (expect FAIL first — handler not yet compiled / then PASS):
`dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~AutoLoginAuthenticationHandlerTests"`
**Step 3: Build + run + commit**
`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj` (0 warnings); tests green, then:
```bash
git add src/ZB.MOM.WW.ScadaBridge.Security/Auth/AutoLoginAuthenticationHandler.cs \
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/AutoLoginAuthenticationHandlerTests.cs
git commit -m "feat(security): AutoLoginAuthenticationHandler — all-roles system-wide dev auto-login (#disable-login)"
```
**Acceptance:** handler authenticates every request as the configured user with all four roles and **no** site-scope claims; blank user → `multi-role`; sign-in/out no-ops don't throw; claim shape matches a real all-roles system-wide login (via `SessionClaimBuilder`).
---
## Task 3: Wire the flag into `AddSecurity` + Host, with startup warning
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Depends on:** Task 1, Task 2
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs` (the `AddSecurity` signature ~line 40 + the `AddAuthentication(...).AddCookie(...)` block ~line 107)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs` (~line 124, the `AddSecurity()` call)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs` (new)
**Context:** `AddSecurity()` currently takes no config (Options-pattern only; the Host owns config-coupled wiring like `AddZbLdapAuth`). The scheme choice is build-time, so the flag is passed in as a `bool`. The Host binds `AuthDisableLoginOptions` (so the handler can resolve `User`) and reads the flag.
**Step 1: Change `AddSecurity` to accept the flag and branch the scheme.**
Change the signature:
```csharp
public static IServiceCollection AddSecurity(this IServiceCollection services, bool disableLogin = false)
```
Replace the `services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => { ... });` registration (~lines 107128) with a branch:
```csharp
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 every authorization policy (which names this scheme)
// authenticates through it with all roles — zero policy changes. No cookie is written;
// OnValidatePrincipal (idle/refresh) does not apply in this mode. See AuthDisableLoginOptions.
authBuilder.AddScheme<AuthenticationSchemeOptions, Auth.AutoLoginAuthenticationHandler>(
CookieAuthenticationDefaults.AuthenticationScheme, _ => { });
// Loud, once-at-first-resolve warning (mirrors OtOpcUa).
services.AddOptions<Auth.AuthDisableLoginOptions>()
.PostConfigure<ILoggerFactory>((opts, lf) =>
lf.CreateLogger("ZB.MOM.WW.ScadaBridge.Security").LogWarning(
"AUTH DISABLED (ScadaBridge:Security:Auth:DisableLogin=true) — every request is " +
"authenticated as '{User}' with FULL permissions ({Roles}) across ALL sites. This " +
"is a SCADA control surface; dev/test ONLY — never enable in production.",
opts.User, string.Join(",", Roles.All)));
}
else
{
authBuilder.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/auth/logout";
options.Events.OnValidatePrincipal = OnValidatePrincipalAsync;
});
}
```
Keep the existing `services.AddOptions<CookieAuthenticationOptions>(...)` PostConfigure (cookie hardening) as-is — it is harmless when the auto-login scheme is active (no cookie handler consumes it). Add a brief comment noting that.
**Step 2: Wire the Host** — in `Program.cs`, replace `builder.Services.AddSecurity();` (~line 124) with:
```csharp
// Dev disable-login flag (config-coupled, so read + bound here at the composition root,
// mirroring AddZbLdapAuth). Default false. See AuthDisableLoginOptions / disable-login design doc.
builder.Services.AddOptions<ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions>()
.Bind(builder.Configuration.GetSection(
ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName));
var disableLogin = builder.Configuration
.GetSection(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.SectionName)
.GetValue<bool>(nameof(ZB.MOM.WW.ScadaBridge.Security.Auth.AuthDisableLoginOptions.DisableLogin));
builder.Services.AddSecurity(disableLogin);
```
(Confirm the needed `using Microsoft.Extensions.Logging;` is present in `ServiceCollectionExtensions.cs` for the warning; add if missing — TreatWarningsAsErrors will flag an unused using, so only add if used.)
**Step 3: Registration-switch test** — assert the scheme's handler type flips with the flag:
```csharp
// tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
using Xunit;
public class DisableLoginRegistrationTests
{
private static async Task<AuthenticationScheme?> ResolveCookieSchemeAsync(bool disableLogin)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSecurity(disableLogin);
await using var sp = services.BuildServiceProvider();
var provider = sp.GetRequiredService<IAuthenticationSchemeProvider>();
return await provider.GetSchemeAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
[Fact]
public async Task FlagTrue_RegistersAutoLoginHandlerUnderCookieScheme()
{
var scheme = await ResolveCookieSchemeAsync(disableLogin: true);
Assert.Equal(typeof(AutoLoginAuthenticationHandler), scheme!.HandlerType);
}
[Fact]
public async Task FlagFalse_RegistersCookieHandler()
{
var scheme = await ResolveCookieSchemeAsync(disableLogin: false);
Assert.Equal(typeof(CookieAuthenticationHandler), scheme!.HandlerType);
}
}
```
> NOTE for the implementer: `AddSecurity` may resolve other dependencies; if `BuildServiceProvider` throws because a collaborator needs more registrations, register the minimal extras the test needs (or assert against the `AuthenticationSchemeOptions`/scheme map another way). Keep the test focused on the scheme→handler-type switch. If full `AddSecurity` resolution is impractical in a unit test, narrow the test to call only the auth-registration portion (extract a small internal helper if needed) — surface this as a plan note rather than over-registering.
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests --filter "FullyQualifiedName~DisableLoginRegistrationTests"`
**Step 4: Build + commit**
`dotnet build src/ZB.MOM.WW.ScadaBridge.Security/...csproj` and `dotnet build src/ZB.MOM.WW.ScadaBridge.Host/...csproj` (0 warnings); tests green, then:
```bash
git add src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs \
src/ZB.MOM.WW.ScadaBridge.Host/Program.cs \
tests/ZB.MOM.WW.ScadaBridge.Security.Tests/DisableLoginRegistrationTests.cs
git commit -m "feat(security): wire DisableLogin flag — auto-login scheme + startup warning (#disable-login)"
```
**Acceptance:** flag true → cookie scheme resolves to `AutoLoginAuthenticationHandler` + loud warning logged; flag false → unchanged cookie handler + M2.19 `OnValidatePrincipal`; Host reads/binds the flag.
---
## Task 4: Docs + dev config note
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Depends on:** Task 3
**Files:**
- Modify: `docs/requirements/Component-Security.md` (add a short "Dev disable-login flag" subsection)
- Modify: the dev/docker appsettings used for local runs (e.g. `docker/central-node-a/appsettings.Central.json` and `-b`, OR `src/ZB.MOM.WW.ScadaBridge.Host/appsettings.json`) — add the `ScadaBridge:Security:Auth:DisableLogin: false` key as discoverable documentation (shipped **false**).
**Step 1:** In `Component-Security.md`, add:
> **Dev disable-login flag (`ScadaBridge:Security:Auth:DisableLogin`)** — when `true`, the Central
> UI bypasses login and auto-authenticates every request as `Security:Auth:User` (default
> `multi-role`) with all roles, system-wide, via `AutoLoginAuthenticationHandler` registered under
> the cookie scheme. Default `false`. **No environment guard** — a loud startup warning is the
> only protection. Dev/test ONLY; never enable in production. Set via env var
> `ScadaBridge__Security__Auth__DisableLogin=true` in local/docker-dev.
**Step 2:** Add the key (value `false`) to the chosen appsettings file(s) so it is discoverable.
**Step 3: Commit**
```bash
git add docs/requirements/Component-Security.md <appsettings files touched>
git commit -m "docs(security): document dev disable-login flag (#disable-login)"
```
**Acceptance:** the flag is documented in the security component doc and visible (false) in a dev appsettings.
---
## Final step (after all tasks)
Run the one full-solution build and the Security suite:
- `dotnet build ZB.MOM.WW.ScadaBridge.slnx` → 0 warnings / 0 errors.
- `dotnet test tests/ZB.MOM.WW.ScadaBridge.Security.Tests` → green.
Then finishing-a-development-branch (merge/push is the user's call).
## Cross-cutting notes
- DRY: the handler reuses `SessionClaimBuilder` — do NOT hand-roll claims.
- YAGNI: no environment guard, no login-page hiding (unreachable when auth always succeeds), no Bearer-path change.
- The auto-login scheme has no `OnValidatePrincipal`; M2.19 idle/refresh is intentionally bypassed in this dev mode.