de67b45d04
The suite's fixtures had drifted from the shared GLAuth config, so a green run proved nothing about the service-account bind: the only success-path test used admin/admin123, but the directory's admin carries the standard dev password, and the "not an admin" test used a readonly user that does not exist there at all -- it passed via the user-not-found branch rather than the group-missing branch it names. Realign to real users from scadaproj/infra/glauth/config.toml: admin/password (othergroups include GwAdmin, gid 5610) for the success path, and gw-viewer/password (GwReader only, gid 5611) for the bind-succeeds-but-no-role path. Both are published dev credentials documented in glauth.md, not secrets. The gw-viewer test drops its old no-leak assertion on the credential literal: the real password is the word "password", which legitimately occurs in the generic denial text, so the check would fail for the wrong reason. The no-leak property is still covered with a distinctive literal by the wrong-password test. In its place the test now asserts the property this fixture is uniquely able to prove -- an authorization failure must be reported with the same message as an authentication failure, so it cannot be used to enumerate valid accounts. appsettings ships Server=localhost, so document the MxGateway__Ldap__Server override the suite needs to reach the shared GLAuth alongside the existing MXGATEWAY_RUN_LIVE_LDAP_TESTS and ServiceAccountPassword variables. Verified live: Failed: 0, Passed: 5 against 10.100.0.35:3893.
231 lines
11 KiB
C#
231 lines
11 KiB
C#
using System.Security.Claims;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
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.Dashboard;
|
|
using LibraryLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
|
|
|
[Collection(LiveResourcesCollection.Name)]
|
|
[Trait("Category", "LiveLdap")]
|
|
public sealed class DashboardLdapLiveTests
|
|
{
|
|
/// <summary>
|
|
/// The shared dev/test directory issues every human tester the same well-known password, so
|
|
/// the fixtures name it once rather than repeating a literal that drifts per test. This is a
|
|
/// published dev credential (see <c>glauth.md</c> and <c>scadaproj/infra/glauth/config.toml</c>),
|
|
/// not a secret — unlike the service-account bind password, which is never in source and must
|
|
/// arrive via <c>MxGateway__Ldap__ServiceAccountPassword</c>.
|
|
/// </summary>
|
|
private const string SharedDirectoryPassword = "password";
|
|
|
|
/// <summary>
|
|
/// Verifies that <c>admin</c> — a shared-directory user whose <c>othergroups</c> include
|
|
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[LiveLdapFact]
|
|
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
|
{
|
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
SharedDirectoryPassword,
|
|
CancellationToken.None);
|
|
|
|
Assert.True(result.Succeeded);
|
|
Assert.NotNull(result.Principal);
|
|
Assert.Equal("admin", result.Principal.FindFirst(ClaimTypes.NameIdentifier)?.Value);
|
|
Assert.Contains(result.Principal.Claims, claim =>
|
|
claim.Type == DashboardAuthenticationDefaults.LdapGroupClaimType
|
|
&& claim.Value.Contains("GwAdmin", StringComparison.OrdinalIgnoreCase));
|
|
|
|
Assert.Contains(result.Principal.Claims, claim =>
|
|
claim.Type == ClaimTypes.Role
|
|
&& claim.Value == DashboardRoles.Admin);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that <c>gw-viewer</c> — a shared-directory user whose only group is GwReader
|
|
/// (gid 5611), which this suite's GroupToRole map deliberately leaves unmapped — is denied
|
|
/// even though its bind succeeds, and that the denial is indistinguishable from the
|
|
/// unknown-user denial.
|
|
/// </summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[LiveLdapFact]
|
|
public async Task AuthenticateAsync_ViewerMissingGwAdminGroup_FailsIndistinguishably()
|
|
{
|
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"gw-viewer",
|
|
SharedDirectoryPassword,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
|
|
// This test used to assert the failure message did not echo the credential literal.
|
|
// That check cannot survive the move to the shared directory: the real password is the
|
|
// word "password", which legitimately occurs in the generic denial text ("The username
|
|
// or password is invalid, ..."), so the assertion would fail for the wrong reason. The
|
|
// no-leak property is still covered — with a distinctive literal — by
|
|
// AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword below. What is
|
|
// asserted here instead is the property this fixture is actually uniquely able to prove:
|
|
// an authorization failure (valid credentials, no mapped role) must be reported with the
|
|
// same message as an authentication failure, so the response cannot be used to enumerate
|
|
// valid accounts.
|
|
DashboardAuthenticationResult unknownUserResult = await authenticator.AuthenticateAsync(
|
|
"no-such-user-9f3c1",
|
|
"irrelevant-password",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(string.IsNullOrWhiteSpace(result.FailureMessage));
|
|
Assert.Equal(unknownUserResult.FailureMessage, result.FailureMessage);
|
|
}
|
|
|
|
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[LiveLdapFact]
|
|
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
|
{
|
|
// Exercises the user-bind-failure branch: the user exists and the service
|
|
// account search succeeds, but the candidate bind is rejected.
|
|
const string wrongPassword = "definitely-not-the-admin-password";
|
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
wrongPassword,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
Assert.DoesNotContain(wrongPassword, result.FailureMessage, StringComparison.Ordinal);
|
|
}
|
|
|
|
/// <summary>Verifies that authentication with unknown username fails.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[LiveLdapFact]
|
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
|
{
|
|
// Exercises the user-not-found branch: the service-account search returns no
|
|
// entry, so no candidate bind is attempted.
|
|
DashboardAuthenticator authenticator = CreateAuthenticator();
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"no-such-user-9f3c1",
|
|
"irrelevant-password",
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
}
|
|
|
|
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
|
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
|
[LiveLdapFact]
|
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
|
{
|
|
// Exercises the connect-failure path: overriding only the port keeps whatever host
|
|
// the run targets (localhost by default, the shared GLAuth under the
|
|
// MxGateway__Ldap__Server override) while pointing at a port nothing listens on, so
|
|
// the connection error the shared LdapAuthService must absorb into a Fail result —
|
|
// rather than propagate as an exception to the dashboard — is reproduced either way.
|
|
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions() with
|
|
{
|
|
// 1 is a reserved port number that no LDAP server listens on.
|
|
Port = 1,
|
|
});
|
|
|
|
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
|
|
"admin",
|
|
SharedDirectoryPassword,
|
|
CancellationToken.None);
|
|
|
|
Assert.False(result.Succeeded);
|
|
Assert.Null(result.Principal);
|
|
}
|
|
|
|
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
|
|
|
|
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions)
|
|
{
|
|
GatewayOptions gatewayOptions = new()
|
|
{
|
|
Dashboard = new DashboardOptions
|
|
{
|
|
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["GwAdmin"] = DashboardRoles.Admin,
|
|
},
|
|
},
|
|
};
|
|
|
|
return new DashboardAuthenticator(
|
|
new LdapAuthService(ldapOptions),
|
|
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
|
NullLogger<DashboardAuthenticator>.Instance);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the shared library <see cref="LibraryLdapOptions"/> by binding the real
|
|
/// <c>MxGateway:Ldap</c> configuration section the same way production does in
|
|
/// <c>AddZbLdapAuth(configuration, "MxGateway:Ldap")</c>, rather than hand-copying the
|
|
/// gateway shadow <c>LdapOptions</c> defaults field by field.
|
|
/// Binding the section directly onto the shared type means the live tests exercise the
|
|
/// exact option-binding path production uses, pick up every shared field (including
|
|
/// <see cref="LibraryLdapOptions.ConnectionTimeoutMs"/>, which governs the
|
|
/// unreachable-server test's timing) at whatever value the operator configured, and
|
|
/// cannot silently drop a field added to the shared type. The gateway's
|
|
/// <c>appsettings.json</c> seeds the dev directory connection (port 3893, plaintext,
|
|
/// AllowInsecure) but ships <c>Server=localhost</c>, so a run against the shared GLAuth
|
|
/// needs the <c>MxGateway__Ldap__Server=10.100.0.35</c> environment override that the
|
|
/// <c>AddEnvironmentVariables()</c> layer below applies.
|
|
/// </summary>
|
|
private static LibraryLdapOptions LibraryOptions()
|
|
{
|
|
string repositoryRoot = IntegrationTestEnvironment.ResolveRepositoryRoot(AppContext.BaseDirectory);
|
|
string appSettingsPath = Path.Combine(
|
|
repositoryRoot,
|
|
"src",
|
|
"ZB.MOM.WW.MxGateway.Server",
|
|
"appsettings.json");
|
|
|
|
IConfiguration configuration = new ConfigurationBuilder()
|
|
.AddJsonFile(appSettingsPath, optional: false)
|
|
.AddEnvironmentVariables()
|
|
.Build();
|
|
|
|
// Same section production binds in AddZbLdapAuth(configuration, "MxGateway:Ldap").
|
|
// Get<T> returns null only when the section is absent; appsettings.json always
|
|
// carries it, so fall back to shared defaults defensively rather than throw.
|
|
LibraryLdapOptions options = configuration.GetSection("MxGateway:Ldap").Get<LibraryLdapOptions>()
|
|
?? new LibraryLdapOptions();
|
|
|
|
// appsettings.json now ships the bind password as the unexpanded
|
|
// "${secret:ldap/mxgateway/bind}" token (resolved at gateway startup by the
|
|
// pre-host secrets expander, which this bare ConfigurationBuilder does not run).
|
|
// AddEnvironmentVariables() above lets MxGateway__Ldap__ServiceAccountPassword
|
|
// override the token with the real password. Fail loud rather than silently
|
|
// binding with the literal token string, which would make every live test in
|
|
// this file fail with a confusing LDAP bind error instead of an actionable one.
|
|
if (string.IsNullOrEmpty(options.ServiceAccountPassword)
|
|
|| options.ServiceAccountPassword.StartsWith("${secret:", StringComparison.Ordinal))
|
|
{
|
|
throw new InvalidOperationException(
|
|
"Live LDAP tests require the real bind password via the "
|
|
+ "MxGateway__Ldap__ServiceAccountPassword environment variable "
|
|
+ "(appsettings now ships a ${secret:} token that this suite does not expand). "
|
|
+ "Set it before running.");
|
|
}
|
|
|
|
return options;
|
|
}
|
|
}
|