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,8 +1,11 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Ldap;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
namespace ZB.MOM.WW.MxGateway.IntegrationTests; namespace ZB.MOM.WW.MxGateway.IntegrationTests;
@@ -28,12 +31,11 @@ public sealed class DashboardLdapLiveTests
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase)); && claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
// IntegrationTests-023: DashboardAuthenticator.CreatePrincipal emits a // IntegrationTests-023: DashboardAuthenticator builds the principal with a
// ClaimTypes.Role claim derived from MapGroupsToRoles. The seeded // ClaimTypes.Role claim resolved from the LDAP groups via the
// GroupToRole map (GwAdmin -> Admin) means the admin principal must // DashboardGroupRoleMapper. The seeded GroupToRole map (GwAdmin -> Admin)
// carry Role=Admin alongside the raw LDAP-group claim. A regression in // means the admin principal must carry Role=Admin alongside the raw LDAP-group
// MapGroupsToRoles (returning an empty list, missing the RDN fallback) // claim. A regression in the group→role mapping would fail this assertion.
// would silently pass without this assertion.
Assert.Contains(result.Principal.Claims, claim => Assert.Contains(result.Principal.Claims, claim =>
claim.Type == ClaimTypes.Role claim.Type == ClaimTypes.Role
&& claim.Value == DashboardRoles.Admin); && claim.Value == DashboardRoles.Admin);
@@ -59,7 +61,7 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword() public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
{ {
// Exercises the LdapException branch: the user exists and the service // Exercises the user-bind-failure branch: the user exists and the service
// account search succeeds, but the candidate bind is rejected. // account search succeeds, but the candidate bind is rejected.
const string wrongPassword = "definitely-not-the-admin-password"; const string wrongPassword = "definitely-not-the-admin-password";
DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticator authenticator = CreateAuthenticator();
@@ -78,8 +80,8 @@ public sealed class DashboardLdapLiveTests
[LiveLdapFact] [LiveLdapFact]
public async Task AuthenticateAsync_UnknownUsername_Fails() public async Task AuthenticateAsync_UnknownUsername_Fails()
{ {
// Exercises the `candidate is null` branch: the service-account search // Exercises the user-not-found branch: the service-account search returns no
// returns no entry, so no candidate bind is attempted. // entry, so no candidate bind is attempted.
DashboardAuthenticator authenticator = CreateAuthenticator(); DashboardAuthenticator authenticator = CreateAuthenticator();
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
@@ -96,18 +98,13 @@ public sealed class DashboardLdapLiveTests
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing() public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
{ {
// Exercises the connect-failure path: a closed loopback port produces a // Exercises the connect-failure path: a closed loopback port produces a
// connection error that DashboardAuthenticator must absorb into a Fail // connection error that the shared LdapAuthService must absorb into a Fail
// result rather than propagating an exception to the dashboard. // result rather than propagating an exception to the dashboard.
DashboardAuthenticator authenticator = new( DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
Options.Create(new GatewayOptions {
{ // 1 is a reserved port number that no LDAP server listens on.
Ldap = new LdapOptions Port = 1,
{ });
// 1 is a reserved port number that no LDAP server listens on.
Port = 1,
},
}),
NullLogger<DashboardAuthenticator>.Instance);
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin", "admin",
@@ -118,19 +115,48 @@ public sealed class DashboardLdapLiveTests
Assert.Null(result.Principal); Assert.Null(result.Principal);
} }
private static DashboardAuthenticator CreateAuthenticator() private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
{ {
return new DashboardAuthenticator( GatewayOptions gatewayOptions = new()
Options.Create(new GatewayOptions {
Dashboard = new DashboardOptions
{ {
Dashboard = new DashboardOptions GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{ {
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) ["GwAdmin"] = DashboardRoles.Admin,
{
["GwAdmin"] = DashboardRoles.Admin,
},
}, },
}), },
};
return new DashboardAuthenticator(
new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
NullLogger<DashboardAuthenticator>.Instance); NullLogger<DashboardAuthenticator>.Instance);
} }
/// <summary>
/// Builds the shared library <see cref="LibraryLdapOptions"/> from the gateway's
/// default LDAP settings so the live tests exercise the same seeded directory the
/// gateway connects to (localhost:3893, plaintext, with AllowInsecure for dev).
/// </summary>
private static LibraryLdapOptions LibraryOptions()
{
ZB.MOM.WW.MxGateway.Server.Configuration.LdapOptions gateway = new();
return new LibraryLdapOptions
{
Enabled = gateway.Enabled,
Server = gateway.Server,
Port = gateway.Port,
Transport = gateway.Transport,
AllowInsecure = gateway.AllowInsecure,
SearchBase = gateway.SearchBase,
ServiceAccountDn = gateway.ServiceAccountDn,
ServiceAccountPassword = gateway.ServiceAccountPassword,
UserNameAttribute = gateway.UserNameAttribute,
DisplayNameAttribute = gateway.DisplayNameAttribute,
GroupAttribute = gateway.GroupAttribute,
};
}
} }
@@ -4,8 +4,8 @@ public sealed record EffectiveLdapConfiguration(
bool Enabled, bool Enabled,
string Server, string Server,
int Port, int Port,
bool UseTls, string Transport,
bool AllowInsecureLdap, bool AllowInsecure,
string SearchBase, string SearchBase,
string ServiceAccountDn, string ServiceAccountDn,
string ServiceAccountPassword, string ServiceAccountPassword,
@@ -23,8 +23,8 @@ public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> option
Enabled: value.Ldap.Enabled, Enabled: value.Ldap.Enabled,
Server: value.Ldap.Server, Server: value.Ldap.Server,
Port: value.Ldap.Port, Port: value.Ldap.Port,
UseTls: value.Ldap.UseTls, Transport: value.Ldap.Transport.ToString(),
AllowInsecureLdap: value.Ldap.AllowInsecureLdap, AllowInsecure: value.Ldap.AllowInsecure,
SearchBase: value.Ldap.SearchBase, SearchBase: value.Ldap.SearchBase,
ServiceAccountDn: value.Ldap.ServiceAccountDn, ServiceAccountDn: value.Ldap.ServiceAccountDn,
ServiceAccountPassword: RedactedValue, ServiceAccountPassword: RedactedValue,
@@ -1,3 +1,4 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Configuration; using ZB.MOM.WW.Configuration;
using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts;
@@ -82,9 +83,9 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
builder); builder);
builder.Port(options.Port, "MxGateway:Ldap:Port"); builder.Port(options.Port, "MxGateway:Ldap:Port");
if (!options.UseTls && !options.AllowInsecureLdap) if (options.Transport == LdapTransport.None && !options.AllowInsecure)
{ {
builder.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false."); builder.Add("MxGateway:Ldap:AllowInsecure must be true when Transport is None (plaintext).");
} }
} }
@@ -1,3 +1,5 @@
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Configuration; namespace ZB.MOM.WW.MxGateway.Server.Configuration;
public sealed class LdapOptions public sealed class LdapOptions
@@ -11,11 +13,18 @@ public sealed class LdapOptions
/// <summary>Gets the LDAP server port.</summary> /// <summary>Gets the LDAP server port.</summary>
public int Port { get; init; } = 3893; public int Port { get; init; } = 3893;
/// <summary>Gets a value indicating whether TLS is required for the connection.</summary> /// <summary>
public bool UseTls { get; init; } /// Gets the transport/TLS mode for the LDAP connection. Replaces the former
/// boolean <c>UseTls</c> (true ≈ <see cref="LdapTransport.Ldaps"/>, false =
/// <see cref="LdapTransport.None"/>). <see cref="LdapTransport.StartTls"/> upgrades
/// a plaintext connection to TLS. Matches the shared
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions.Transport"/> field so the
/// <c>MxGateway:Ldap</c> section binds straight onto the shared options.
/// </summary>
public LdapTransport Transport { get; init; } = LdapTransport.None;
/// <summary>Gets a value indicating whether insecure LDAP connections are allowed.</summary> /// <summary>Gets a value indicating whether insecure (plaintext) LDAP connections are allowed.</summary>
public bool AllowInsecureLdap { get; init; } = true; public bool AllowInsecure { get; init; } = true;
/// <summary>Gets the LDAP search base distinguished name.</summary> /// <summary>Gets the LDAP search base distinguished name.</summary>
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local"; public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
@@ -26,7 +26,7 @@ else
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr> <tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr> <tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr> <tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr> <tr><th scope="row">LDAP transport</th><td>@Snapshot.Configuration.Ldap.Transport</td></tr>
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr> <tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr> <tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr> <tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
@@ -1,14 +1,25 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text; using ZB.MOM.WW.Auth.Abstractions.Ldap;
using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using Novell.Directory.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Authenticates interactive dashboard logins against LDAP. The bind/search
/// mechanics are delegated to the shared <see cref="ILdapAuthService"/>
/// (<c>ZB.MOM.WW.Auth.Ldap</c>), which performs bind-then-search, fails closed,
/// and never throws — returning the user's display name and LDAP groups on
/// success. This class keeps the dashboard-specific policy: groups are resolved
/// to dashboard roles via <see cref="IGroupRoleMapper{TRole}"/>, a login with no
/// matching role is denied, and the resulting <see cref="ClaimsPrincipal"/> is
/// shaped exactly as before (see <see cref="CreatePrincipal"/>).
/// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles (Task 1.1 seam).</param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator( public sealed class DashboardAuthenticator(
IOptions<GatewayOptions> options, ILdapAuthService ldapAuthService,
IGroupRoleMapper<string> roleMapper,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{ {
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized."; private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -19,202 +30,46 @@ public sealed class DashboardAuthenticator(
string? password, string? password,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
LdapOptions ldapOptions = options.Value.Ldap; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
DashboardOptions dashboardOptions = options.Value.Dashboard;
if (!ldapOptions.Enabled
|| string.IsNullOrWhiteSpace(username)
|| string.IsNullOrWhiteSpace(password))
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
{ {
return DashboardAuthenticationResult.Fail(GenericFailureMessage); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
} }
string normalizedUsername = username.Trim(); string normalizedUsername = username.Trim();
try // The shared service owns connect/bind/search and the fail-closed contract:
{ // it returns Fail(Disabled) when LDAP is off, enforces TLS-or-AllowInsecure via
using LdapConnection connection = new(); // its startup validator, and never throws. We only translate its outcome into a
connection.SecureSocketLayer = ldapOptions.UseTls; // dashboard principal here.
LdapAuthResult ldapResult = await ldapAuthService
await Task.Run( .AuthenticateAsync(normalizedUsername, password, cancellationToken)
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? candidate = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (candidate is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
await Task.Run(
() => connection.Bind(candidate.Dn, password),
cancellationToken)
.ConfigureAwait(false);
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
LdapEntry? authenticatedEntry = await SearchUserAsync(
connection,
ldapOptions,
normalizedUsername,
cancellationToken)
.ConfigureAwait(false);
if (authenticatedEntry is null)
{
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
?? normalizedUsername;
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
IReadOnlyList<string> roles = MapGroupsToRoles(groups, dashboardOptions.GroupToRole);
if (roles.Count == 0)
{
logger.LogInformation(
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
return DashboardAuthenticationResult.Success(CreatePrincipal(
normalizedUsername,
displayName,
groups,
roles));
}
catch (OperationCanceledException)
{
throw;
}
catch (LdapException ex)
{
logger.LogInformation(
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
normalizedUsername,
ex.ResultCode);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
}
}
/// <summary>Escapes special characters in LDAP filter strings.</summary>
/// <param name="value">The string value to escape.</param>
internal static string EscapeLdapFilter(string value)
{
StringBuilder builder = new(value.Length);
foreach (char character in value)
{
builder.Append(character switch
{
'\\' => @"\5c",
'*' => @"\2a",
'(' => @"\28",
')' => @"\29",
'\0' => @"\00",
_ => character.ToString()
});
}
return builder.ToString();
}
/// <summary>
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
/// multiple roles; Admin and Viewer are the only legal values. Returns
/// an empty list when no group matches (caller rejects the login).
/// Delegates to <see cref="DashboardGroupRoleMapping"/>, the single source
/// of truth shared with <see cref="DashboardGroupRoleMapper"/>.
/// </summary>
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
internal static IReadOnlyList<string> MapGroupsToRoles(
IEnumerable<string> groups,
IReadOnlyDictionary<string, string> groupToRole)
=> DashboardGroupRoleMapping.MapGroupsToRoles(groups, groupToRole);
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
/// <param name="distinguishedName">The LDAP distinguished name.</param>
internal static string ExtractFirstRdnValue(string distinguishedName)
=> DashboardGroupRoleMapping.ExtractFirstRdnValue(distinguishedName);
private static Task BindServiceAccountAsync(
LdapConnection connection,
LdapOptions ldapOptions,
CancellationToken cancellationToken)
{
return Task.Run(
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
cancellationToken);
}
private static async Task<LdapEntry?> SearchUserAsync(
LdapConnection connection,
LdapOptions ldapOptions,
string username,
CancellationToken cancellationToken)
{
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
ILdapSearchResults results = await Task.Run(
() => connection.Search(
ldapOptions.SearchBase,
LdapConnection.ScopeSub,
filter,
attrs: null,
typesOnly: false),
cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
LdapEntry? entry = null; if (!ldapResult.Succeeded)
while (results.HasMore())
{ {
LdapEntry next = results.Next(); return DashboardAuthenticationResult.Fail(GenericFailureMessage);
if (entry is not null)
{
return null;
}
entry = next;
} }
return entry; GroupRoleMapping<string> mapping = await roleMapper
} .MapAsync(ldapResult.Groups, cancellationToken)
.ConfigureAwait(false);
private static string? ReadAttribute(LdapEntry entry, string attributeName) IReadOnlyList<string> roles = mapping.Roles;
{ if (roles.Count == 0)
return ReadLdapAttribute(entry, attributeName)?.StringValue; {
} // Preserve the long-standing "no roles matched -> login denied" rule.
logger.LogInformation(
"LDAP dashboard login denied for user {User}: no GroupToRole mapping matched their LDAP groups.",
ldapResult.Username);
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName) return DashboardAuthenticationResult.Fail(GenericFailureMessage);
{ }
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
return attribute?.StringValueArray ?? [];
}
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName) return DashboardAuthenticationResult.Success(CreatePrincipal(
{ ldapResult.Username,
return entry.GetAttribute(attributeName) ldapResult.DisplayName,
?? entry.GetAttribute(attributeName.ToLowerInvariant()) ldapResult.Groups,
?? entry.GetAttribute(attributeName.ToUpperInvariant()); roles));
} }
private static ClaimsPrincipal CreatePrincipal( private static ClaimsPrincipal CreatePrincipal(
@@ -1,8 +1,10 @@
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration; using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -16,8 +18,21 @@ public static class DashboardServiceCollectionExtensions
/// Registers all dashboard services, authentication, and Razor components. /// Registers all dashboard services, authentication, and Razor components.
/// </summary> /// </summary>
/// <param name="services">Service collection to register services.</param> /// <param name="services">Service collection to register services.</param>
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services) /// <param name="configuration">
/// Application configuration, used to bind the shared LDAP provider's options
/// from the <c>MxGateway:Ldap</c> section.
/// </param>
public static IServiceCollection AddGatewayDashboard(
this IServiceCollection services,
IConfiguration configuration)
{ {
// Dashboard logins delegate bind/search to the shared ZB.MOM.WW.Auth.Ldap
// provider. Its LdapOptions bind straight from MxGateway:Ldap (the gateway's
// LdapOptions field names match the shared options: Transport / AllowInsecure /
// SearchBase / ServiceAccount* / *Attribute). AddZbLdapAuth also adds a
// ValidateOnStart() so an insecure-transport misconfiguration fails fast at boot.
services.AddZbLdapAuth(configuration, "MxGateway:Ldap");
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>(); services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>(); services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>(); services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
@@ -91,7 +91,7 @@ public static class GatewayApplication
builder.Services.AddWorkerProcessLauncher(); builder.Services.AddWorkerProcessLauncher();
builder.Services.AddGatewaySessions(); builder.Services.AddGatewaySessions();
builder.Services.AddGatewayAlarms(); builder.Services.AddGatewayAlarms();
builder.Services.AddGatewayDashboard(); builder.Services.AddGatewayDashboard(builder.Configuration);
builder.Services.AddGalaxyRepository(); builder.Services.AddGalaxyRepository();
return builder; return builder;
@@ -7,6 +7,8 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" /> <PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" /> <PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
@@ -17,7 +19,6 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Novell.Directory.Ldap.NETStandard" Version="3.6.0" />
<PackageReference Include="Polly.Core" Version="8.6.6" /> <PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup> </ItemGroup>
@@ -22,8 +22,8 @@
"Enabled": true, "Enabled": true,
"Server": "localhost", "Server": "localhost",
"Port": 3893, "Port": 3893,
"UseTls": false, "Transport": "None",
"AllowInsecureLdap": true, "AllowInsecure": true,
"SearchBase": "dc=lmxopcua,dc=local", "SearchBase": "dc=lmxopcua,dc=local",
"ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local", "ServiceAccountDn": "cn=serviceaccount,dc=lmxopcua,dc=local",
"ServiceAccountPassword": "serviceaccount123", "ServiceAccountPassword": "serviceaccount123",
@@ -1,90 +1,74 @@
using System.Security.Claims;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; 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.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.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 public sealed class DashboardAuthenticatorTests
{ {
/// <summary>Verifies that LDAP filter special characters are escaped correctly.</summary> /// <summary>A blank username is rejected without touching the LDAP provider.</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>
[Theory] [Theory]
[InlineData("GwAdmin", DashboardRoles.Admin)] [InlineData(null)]
[InlineData("gwadmin", DashboardRoles.Admin)] [InlineData("")]
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)] [InlineData(" ")]
[InlineData("OtherGroup", null)] public async Task AuthenticateAsync_BlankUsername_FailsWithoutCallingLdap(string? username)
public void MapGroupsToRoles_ResolvesByShortNameAndDistinguishedName(
string ldapGroup,
string? expectedRole)
{ {
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase) FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
{ DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles([ldapGroup], mapping); DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
username,
"password",
CancellationToken.None);
if (expectedRole is null) Assert.False(result.Succeeded);
{ Assert.Null(result.Principal);
Assert.Empty(roles); Assert.False(ldap.WasCalled);
}
else
{
Assert.Equal(expectedRole, Assert.Single(roles));
}
} }
/// <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] [Fact]
public void MapGroupsToRoles_AdminPlusViewer_BothRolesEmitted() public async Task AuthenticateAsync_BlankPassword_FailsWithoutCallingLdap()
{ {
Dictionary<string, string> mapping = new(StringComparer.OrdinalIgnoreCase) FakeLdapAuthService ldap = new(LdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
{ DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
};
IReadOnlyList<string> roles = DashboardAuthenticator.MapGroupsToRoles( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
["GwAdmin", "GwReader"], "admin",
mapping); " ",
CancellationToken.None);
Assert.Contains(DashboardRoles.Admin, roles); Assert.False(result.Succeeded);
Assert.Contains(DashboardRoles.Viewer, roles); Assert.Null(result.Principal);
Assert.False(ldap.WasCalled);
} }
/// <summary>Verifies that extraction returns the leading RDN value from a distinguished name.</summary> /// <summary>
[Fact] /// A failed LDAP outcome (any failure bucket, including <see cref="LdapAuthFailure.Disabled"/>)
public void ExtractFirstRdnValue_ReturnsLeadingRdnValue() /// 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( FakeLdapAuthService ldap = new(LdapAuthResult.Fail(failure));
"CN=Gateway Admins,OU=Groups,DC=example,DC=com"); DashboardAuthenticator authenticator = CreateAuthenticator(ldap, StandardMapping());
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,
},
});
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync( DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"admin", "admin",
@@ -96,10 +80,161 @@ public sealed class DashboardAuthenticatorTests
Assert.DoesNotContain("admin123", result.FailureMessage, StringComparison.Ordinal); 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( return new DashboardAuthenticator(
Options.Create(options), ldapAuthService,
roleMapper,
NullLogger<DashboardAuthenticator>.Instance); 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", "BogusRole",
"MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")] "MxGateway:Dashboard:GroupToRole['GwAdmin'] must be 'Admin' or 'Viewer'.")]
[InlineData( [InlineData(
"MxGateway:Ldap:AllowInsecureLdap", "MxGateway:Ldap:AllowInsecure",
"false", "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( public async Task StartAsync_InvalidGatewayConfiguration_FailsStartup(
string key, string key,
string value, string value,