using System.Diagnostics.CodeAnalysis;
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.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Sessions;
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
{
///
/// 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 glauth.md and scadaproj/infra/glauth/config.toml),
/// not a secret — unlike the service-account bind password, which is never in source and must
/// arrive via MxGateway__Ldap__ServiceAccountPassword.
///
private const string SharedDirectoryPassword = "password";
///
/// Dashboard visibility tags (SEC-25) used by the ACL scenarios below. They are operator-chosen
/// labels that exist only in this fixture's configuration and in the fake sessions' owner-tag
/// list — nothing in the shared directory carries them.
///
private const string TeamATag = "team-a";
private const string TeamBTag = "team-b";
private const string TeamASessionId = "session-team-a";
private const string TeamBSessionId = "session-team-b";
private const string UntaggedSessionId = "session-untagged";
///
/// Verifies that admin — a shared-directory user whose othergroups include
/// GwAdmin (gid 5610) — authenticates successfully and is granted the Admin dashboard role.
///
/// A task that represents the asynchronous operation.
[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);
}
///
/// Verifies that gw-viewer — 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.
///
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that authentication with wrong password fails without leaking the password.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that authentication with unknown username fails.
/// A task that represents the asynchronous operation.
[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);
}
/// Verifies that authentication fails gracefully when the server is unreachable.
/// A task that represents the asynchronous operation.
[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);
}
///
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: gw-viewer's only
/// group (GwReader) is mapped to the team-a visibility tag by Dashboard:GroupToTag,
/// and the principal that bind produces is admitted by for a
/// team-a-tagged session but refused for a team-b-tagged one.
///
///
/// The mapping under test is entirely config-side: no GLAuth entry, group, or membership was
/// added for it — the shared directory's existing GwReader group is simply named as a key in
/// this fixture's GroupToTag map. What only a live bind can prove is that the group
/// names ILdapAuthService actually returns from the shared directory (short RDN values,
/// not DNs) are the ones GroupToTag keys match, which a fabricated principal cannot show.
/// The denial half is the load-bearing assertion: before the ACL, every Viewer saw every session.
///
/// A task that represents the asynchronous operation.
[LiveLdapFact]
public async Task AuthenticateAsync_ViewerWithGroupToTagGrant_SeesOnlyItsOwnTaggedSession()
{
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"gw-viewer",
SharedDirectoryPassword,
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.True(result.Principal.IsInRole(DashboardRoles.Viewer));
Assert.False(result.Principal.IsInRole(DashboardRoles.Admin));
Assert.Contains(result.Principal.Claims, claim =>
claim.Type == DashboardAuthenticationDefaults.DashboardTagClaimType
&& claim.Value == TeamATag);
IDashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
Assert.False(acl.CanViewSession(result.Principal, TeamBSessionId));
// Untagged sessions stay Admin-only under the shipped default, so the Viewer's grant does
// not silently widen to sessions whose owning key declared no tags.
Assert.False(acl.CanViewSession(result.Principal, UntaggedSessionId));
}
///
/// Verifies that multi-role — an Administrator in the shared directory — reaches every
/// session regardless of tags.
///
///
/// The bypass is proved by the two sessions the account's own grant does not cover.
/// multi-role is a member of GwReader as well as GwAdmin, so this fixture's
/// GroupToTag map grants it team-a — the team-a allow would therefore hold
/// even with the bypass removed and proves nothing on its own. team-b (a tag it does not
/// hold) and the untagged session (Admin-only under the shipped default) are the assertions
/// that fail if the Administrator branch is ever dropped.
///
/// A task that represents the asynchronous operation.
[LiveLdapFact]
public async Task AuthenticateAsync_Administrator_BypassesTagCheckForEverySession()
{
DashboardAuthenticator authenticator = CreateAuthenticator(LibraryOptions(), TaggedDashboardOptions());
DashboardAuthenticationResult result = await authenticator.AuthenticateAsync(
"multi-role",
SharedDirectoryPassword,
CancellationToken.None);
Assert.True(result.Succeeded);
Assert.NotNull(result.Principal);
Assert.True(result.Principal.IsInRole(DashboardRoles.Admin));
IDashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(result.Principal, TeamASessionId));
Assert.True(acl.CanViewSession(result.Principal, TeamBSessionId));
Assert.True(acl.CanViewSession(result.Principal, UntaggedSessionId));
}
private static DashboardAuthenticator CreateAuthenticator() => CreateAuthenticator(LibraryOptions());
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) =>
CreateAuthenticator(ldapOptions, AdminOnlyDashboardOptions());
private static DashboardAuthenticator CreateAuthenticator(
LibraryLdapOptions ldapOptions,
DashboardOptions dashboardOptions)
{
GatewayOptions gatewayOptions = new() { Dashboard = dashboardOptions };
return new DashboardAuthenticator(
new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
Options.Create(gatewayOptions),
NullLogger.Instance);
}
///
/// The historical fixture map: GwAdmin is the only mapped group, so GwReader members are denied
/// login outright. Kept for the tests that assert that denial.
///
private static DashboardOptions AdminOnlyDashboardOptions() => new()
{
GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
},
};
///
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted team-a. Both keys
/// name groups that already exist in the shared directory — the tag layer is config-only.
///
private static DashboardOptions TaggedDashboardOptions() => new()
{
GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
},
GroupToTag = new Dictionary(StringComparer.OrdinalIgnoreCase)
{
["GwReader"] = [TeamATag],
},
};
private static DashboardSessionAcl CreateAcl() => new(
new FixedSessionManager(
[
CreateSession(TeamASessionId, [TeamATag]),
CreateSession(TeamBSessionId, [TeamBTag]),
CreateSession(UntaggedSessionId, tags: null),
]),
Options.Create(new GatewayOptions
{
// Explicit rather than defaulted: the untagged assertions above read this value.
Dashboard = new DashboardOptions
{
UntaggedSessionVisibility = UntaggedSessionVisibility.AdminOnly,
},
}));
private static GatewaySession CreateSession(string sessionId, string[]? tags) => new(
sessionId: sessionId,
backendName: "backend",
pipeName: $"pipe-{sessionId}",
nonce: "nonce",
clientIdentity: "client",
ownerKeyId: "key-1",
clientSessionName: "client-session",
clientCorrelationId: "correlation",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UnixEpoch,
ownerDashboardTags: tags);
///
/// Builds the shared library by binding the real
/// MxGateway:Ldap configuration section the same way production does in
/// AddZbLdapAuth(configuration, "MxGateway:Ldap"), rather than hand-copying the
/// gateway shadow LdapOptions 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
/// , 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
/// appsettings.json seeds the dev directory connection (port 3893, plaintext,
/// AllowInsecure) but ships Server=localhost, so a run against the shared GLAuth
/// needs the MxGateway__Ldap__Server=10.100.0.35 environment override that the
/// AddEnvironmentVariables() layer below applies.
///
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 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()
?? 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;
}
///
/// Registry double serving a fixed set of sessions. The ACL only ever calls
/// ; the remaining members exist to satisfy the interface and are
/// never reached by these tests.
///
/// The sessions this registry resolves.
private sealed class FixedSessionManager(IReadOnlyList sessions) : ISessionManager
{
///
public Task OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => throw new NotSupportedException();
///
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessions.FirstOrDefault(candidate => candidate.SessionId == sessionId);
return session is not null;
}
///
public Task InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => throw new NotSupportedException();
///
public Task CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
///
public Task KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) => throw new NotSupportedException();
///
public Task CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
///
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}