feat(auth): cut MxGateway dashboard LDAP over to ZB.MOM.WW.Auth.Ldap; roles via IGroupRoleMapper (Task 1.2/1.4)

This commit is contained in:
Joseph Doherty
2026-06-02 00:51:10 -04:00
parent 792e3f9445
commit c3b466e13d
13 changed files with 344 additions and 302 deletions
@@ -1,90 +1,74 @@
using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Parity tests for <see cref="DashboardAuthenticator"/> after the cutover to the
/// shared <see cref="ILdapAuthService"/>. The library now owns connect/bind/search
/// (covered by its own tests); these tests fake the shared service to return known
/// groups and assert the dashboard-specific policy is unchanged: groups → roles via
/// <see cref="IGroupRoleMapper{TRole}"/>, no-roles-matched is denied, and the
/// resulting principal/claims keep their exact shape.
/// </summary>
public sealed class DashboardAuthenticatorTests
{
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary>
[Fact]
public void EscapeLdapFilter_EscapesSpecialCharacters()
{
string escaped = DashboardAuthenticator.EscapeLdapFilter("a\\b*c(d)e\0f");
Assert.Equal("a\\5cb\\2ac\\28d\\29e\\00f", escaped);
}
/// <summary>Verifies that group-to-role mapping resolves by short name and distinguished name.</summary>
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
/// <param name="expectedRole">The expected role or null if no match.</param>
/// <summary>A blank username is rejected without touching the LDAP provider.</summary>
[Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)]
[InlineData("gwadmin", DashboardRoles.Admin)]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
[InlineData("OtherGroup", null)]
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
string ldapGroup,
string? expectedRole)
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
{
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
username,
"password",
CancellationToken.None);
if (expectedRole is null)
{
Assert.Empty(roles);
}
else
{
Assert.Equal(expectedRole, Assert.Single(roles));
}
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.False(ldap.WasCalled);
}
/// <summary>Verifies that admin and viewer roles are both emitted when groups are present.</summary>
/// <summary>A blank password is rejected without touching the LDAP provider.</summary>
[Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted()
public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
{
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles(
["GwAdmin", "GwReader"],
mapping);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
" ",
CancellationToken.None);
Assert.Contains(DashboardRoles.Admin, roles);
Assert.Contains(DashboardRoles.Viewer, roles);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
Assert.False(ldap.WasCalled);
}
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary>
[Fact]
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue()
/// <summary>
/// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
/// maps to the generic dashboard failure without leaking the raw password.
/// </summary>
[Theory]
[InlineData(LdapAuthFailure.Disabled)]
[InlineData(LdapAuthFailure.BadCredentials)]
[InlineData(LdapAuthFailure.UserNotFound)]
[InlineData(LdapAuthFailure.ServiceAccountBindFailed)]
public async Task AuthenticateAsync_LdapFailure_ReturnsFailureWithoutRawCredentials(
LdapAuthFailure failure)
{
string result = DashboardAuthenticator.ExtractFirstRdnValue(
"CN=Gateway Admins,OU=Groups,DC=example,DC=com");
Assert.Equal("Gateway Admins", result);
}
/// <summary>Verifies that authentication fails when LDAP is disabled without exposing raw credentials.</summary>
[Fact]
public async Task AuthenticateAsync_LdapDisabled_ReturnsFailureWithoutRawCredentials()
{
DashboardAuthenticator authenticator = CreateAuthenticator(new GatewayOptions
{
Ldap = new LdapOptions
{
Enabled = false,
},
});
FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
@@ -96,10 +80,161 @@ public sealed class DashboardAuthenticatorTests
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal);
}
private static DashboardAuthenticator CreateAuthenticator(GatewayOptions options)
/// <summary>
/// On success the principal carries the resolved roles, the raw LDAP-group claims,
/// the display name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier),
/// under the dashboard authentication scheme — the exact shape produced before the cutover.
/// </summary>
[Fact]
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "admin",
displayName: "Administrator",
groups: ["GwAdmin", "GwReader"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
Assert.Equal("admin", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
Assert.Equal("Administrator", principal.FindFirst(ClaimTypes.Name)?.Value);
// Identity is built with the dashboard scheme and Name/Role claim types so
// IsInRole and Identity.Name keep working.
ClaimsIdentity identity = Assert.IsType<ClaimsIdentity>(principal.Identity);
Assert.Equal(DashboardAuthenticationDefaults.AuthenticationScheme, identity.AuthenticationType);
Assert.Equal("Administrator", identity.Name);
// Both groups mapped → both roles present.
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
// Raw LDAP groups are surfaced under the dedicated group claim type.
IReadOnlyList<string> groupClaims = principal.FindAll(
DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value)
.ToList();
Assert.Equal(["GwAdmin", "GwReader"], groupClaims);
}
/// <summary>
/// When the user authenticates but none of their groups map to a dashboard role,
/// the login is denied (the long-standing "no roles matched → denied" rule).
/// </summary>
[Fact]
public async Task AuthenticateAsync_NoRolesMatched_DeniesLogin()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "nobody",
displayName: "No Body",
groups: ["SomeUnmappedGroup"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"nobody",
"pw",
CancellationToken.None);
Assert.False(result.Succeeded);
Assert.Null(result.Principal);
}
/// <summary>
/// A group supplied as a full distinguished name still resolves to its role via the
/// mapper's leading-RDN fallback, and the original DN is preserved on the group claim.
/// </summary>
[Fact]
public async Task AuthenticateAsync_GroupAsDistinguishedName_ResolvesRoleAndPreservesGroupClaim()
{
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "admin",
displayName: "admin",
groups: [groupDn]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin",
"admin123",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.Contains(
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
claim => claim.Value == groupDn);
}
/// <summary>The (already-trimmed) username from the LDAP result flows onto the principal.</summary>
[Fact]
public async Task AuthenticateAsync_UsesUsernameFromLdapResult()
{
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
username: "canonical",
displayName: "Canonical User",
groups: ["GwReader"]));
DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
" canonical ",
"pw",
CancellationToken.None);
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
Assert.Equal("canonical", principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
// The authenticator trims before delegating, so the provider sees the canonical value.
Assert.Equal("canonical", ldap.LastUsername);
}
private static DashboardAuthenticator CreateAuthenticator(
ILdapAuthService ldapAuthService,
Dictionary<string, string> groupToRole)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToRole = groupToRole,
},
};
IGroupRoleMapper<string> roleMapper = new DashboardGroupRoleMapper(Options.Create(options));
return new DashboardAuthenticator(
Options.Create(options),
ldapAuthService,
roleMapper,
NullLogger<DashboardAuthenticator>.Instance);
}
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
/// <summary>
/// Fake <see cref="ILdapAuthService"/> returning a fixed result, recording whether it was
/// invoked and with what username so the authenticator's pre-checks and trimming can be asserted.
/// </summary>
private sealed class FakeLdapAuthService(LdapAuthResult result) : ILdapAuthService
{
public bool WasCalled { get; private set; }
public string? LastUsername { get; private set; }
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
{
WasCalled = true;
LastUsername = username;
return Task.FromResult(result);
}
}
}
@@ -199,9 +199,9 @@ public sealed class GatewayApplicationTests
"BogusRole",
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
[InlineData(
"MxGateway:Ldap:AllowInsecureLdap",
"MxGateway:Ldap:AllowInsecure",
"false",
"MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.")]
"MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).")]
public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
string key,
string value,