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
@@ -329,63 +329,16 @@ public static class AuditEndpoints
private readonly record struct AuthOutcome(AuthenticatedUser? User, IResult? Failure);
/// <summary>
/// Decodes HTTP Basic Auth, binds against LDAP, and resolves roles — the same
/// flow <see cref="ManagementEndpoints"/> uses. Returns a populated
/// <see cref="AuthenticatedUser"/> on success, or an <see cref="IResult"/>
/// carrying the 401 response on any failure.
/// Authenticates the audit-REST request via the shared
/// <see cref="ManagementAuthenticator.AuthenticateAsync"/> — the dev/test
/// <c>DisableLogin</c> bypass first, otherwise HTTP Basic → LDAP → roles, the same flow
/// <see cref="ManagementEndpoints"/> uses. Returns a populated <see cref="AuthenticatedUser"/>
/// on success, or an <see cref="IResult"/> carrying the 401 response on any failure.
/// </summary>
private static async Task<AuthOutcome> AuthenticateAsync(HttpContext context)
{
var authHeader = context.Request.Headers.Authorization.ToString();
if (string.IsNullOrEmpty(authHeader) || !authHeader.StartsWith("Basic ", StringComparison.OrdinalIgnoreCase))
{
return new AuthOutcome(null, Results.Json(
new { error = "Authorization header required (Basic scheme).", code = "AUTH_FAILED" }, statusCode: 401));
}
string username, password;
try
{
var decoded = Encoding.UTF8.GetString(Convert.FromBase64String(authHeader["Basic ".Length..]));
var colon = decoded.IndexOf(':');
if (colon < 0) throw new FormatException();
username = decoded[..colon];
password = decoded[(colon + 1)..];
}
catch
{
return new AuthOutcome(null, Results.Json(
new { error = "Malformed Basic Auth header.", code = "AUTH_FAILED" }, statusCode: 401));
}
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
{
return new AuthOutcome(null, Results.Json(
new { error = "Username and password are required.", code = "AUTH_FAILED" }, statusCode: 401));
}
var ldapAuth = context.RequestServices.GetRequiredService<ILdapAuthService>();
var authResult = await ldapAuth.AuthenticateAsync(username, password, context.RequestAborted);
if (!authResult.Succeeded)
{
return new AuthOutcome(null, Results.Json(
new { error = LdapAuthFailureMessages.ToMessage(authResult.Failure), code = "AUTH_FAILED" }, statusCode: 401));
}
var roleMapper = context.RequestServices.GetRequiredService<RoleMapper>();
var mappingResult = await roleMapper.MapGroupsToRolesAsync(authResult.Groups, context.RequestAborted);
var permittedSiteIds = mappingResult.IsSystemWideDeployment
? Array.Empty<string>()
: mappingResult.PermittedSiteIds.ToArray();
var user = new AuthenticatedUser(
authResult.Username,
authResult.DisplayName,
mappingResult.Roles.ToArray(),
permittedSiteIds);
return new AuthOutcome(user, null);
var outcome = await ManagementAuthenticator.AuthenticateAsync(context);
return new AuthOutcome(outcome.User, outcome.Failure);
}
private static bool HasAnyRole(AuthenticatedUser user, string[] allowed) =>