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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user