fix(management): honor DisableLogin on the Basic-Auth CLI surfaces

DisableLogin only swapped the cookie auth scheme (AutoLoginAuthenticationHandler),
which covers the interactive UI. The CLI authenticates POST /management, the audit
REST endpoints, and the SignalR debug-stream hub with HTTP Basic, and each ran its
own hardcoded Basic->LDAP check that ignored DisableLogin. In a login-disabled (e.g.
no-LDAP) deployment that locked the CLI out: every call returned 401 AUTH_FAILED.

Add ManagementAuthenticator, which centralizes the management/CLI auth flow:
when ScadaBridge:Security:Auth:DisableLogin is true it synthesizes the same dev
principal as AutoLoginAuthenticationHandler (configured user, all roles, system-wide)
and bypasses Basic->LDAP; otherwise the unchanged Basic->LDAP flow runs. Wired into
ManagementEndpoints (delegates), AuditEndpoints (delegates), and DebugStreamHub
(bypass branch). +6 unit tests; ManagementService.Tests green (140).
This commit is contained in:
Joseph Doherty
2026-06-16 17:12:17 -04:00
parent cdf0a199cb
commit d312dfb139
5 changed files with 258 additions and 100 deletions
@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.Security.Auth;
namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;
/// <summary>
/// Tests for the dev/test <c>DisableLogin</c> bypass on the management/CLI auth surface
/// (<see cref="ManagementAuthenticator"/>). The bypass lets the HTTP-Basic CLI surfaces
/// authenticate without LDAP when login is disabled — mirroring the interactive cookie
/// handler — so a login-disabled deployment is not locked out of the CLI.
/// </summary>
public class ManagementAuthenticatorTests
{
private static HttpContext ContextWith(AuthDisableLoginOptions options)
{
var services = new ServiceCollection();
services.AddSingleton<IOptions<AuthDisableLoginOptions>>(Options.Create(options));
return new DefaultHttpContext { RequestServices = services.BuildServiceProvider() };
}
[Fact]
public void TryDisableLoginUser_WhenDisabled_ReturnsNull()
{
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false });
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
}
[Fact]
public void TryDisableLoginUser_WhenNoOptionsRegistered_ReturnsNull()
{
var context = new DefaultHttpContext
{
RequestServices = new ServiceCollection().BuildServiceProvider(),
};
Assert.Null(ManagementAuthenticator.TryDisableLoginUser(context));
}
[Fact]
public void TryDisableLoginUser_WhenEnabled_ReturnsDevUserWithAllRolesSystemWide()
{
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = "ci-bot" });
var user = ManagementAuthenticator.TryDisableLoginUser(context);
Assert.NotNull(user);
Assert.Equal("ci-bot", user!.Username);
Assert.Equal("ci-bot", user.DisplayName);
Assert.Equal(Roles.All, user.Roles);
Assert.Empty(user.PermittedSiteIds); // empty == system-wide, mirrors AutoLoginAuthenticationHandler
}
[Fact]
public void TryDisableLoginUser_WhenEnabledWithBlankUser_FallsBackToMultiRole()
{
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true, User = " " });
var user = ManagementAuthenticator.TryDisableLoginUser(context);
Assert.NotNull(user);
Assert.Equal("multi-role", user!.Username);
}
[Fact]
public async Task AuthenticateAsync_WhenDisabled_BypassesBasicAuthAndReturnsDevUser()
{
// No Authorization header and no ILdapAuthService registered: the bypass must short-circuit
// before either is consulted.
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = true });
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.Failure);
Assert.NotNull(outcome.User);
Assert.Equal("multi-role", outcome.User!.Username);
Assert.Equal(Roles.All, outcome.User.Roles);
}
[Fact]
public async Task AuthenticateAsync_WhenEnabledWithNoHeader_ReturnsUnauthorized()
{
// Login NOT disabled and no Basic header: the real auth path rejects with a 401 result
// before any LDAP lookup.
var context = ContextWith(new AuthDisableLoginOptions { DisableLogin = false });
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
Assert.Null(outcome.User);
Assert.NotNull(outcome.Failure);
}
}