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; } }