Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.IntegrationTests/DashboardLdapLiveTests.cs
T
Joseph Doherty 1d8a4a6442 test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented
The per-session dashboard event ACL shipped in 693a78d + 7ec0b35 with unit
coverage over a fabricated principal. What a fabricated principal cannot show is
that the group names the shared directory actually returns -- short RDN values,
not DNs -- are the ones Dashboard:GroupToTag keys match. Two [LiveLdapFact]s
close that: gw-viewer binds for real, its GwReader membership grants team-a, and
IDashboardSessionAcl then admits a team-a-tagged session and refuses a
team-b-tagged one; multi-role takes the Administrator bypass. The mapping is
config-side only -- no GLAuth entry, group, or membership was added, and
glauth.md records that explicitly so a future reader does not go looking for a
directory change that never happened.

multi-role is a member of GwReader as well as GwAdmin, so it holds team-a too.
Its bypass is therefore asserted on team-b and on the untagged session -- the two
it would lose if the Administrator branch were ever dropped -- rather than on
team-a, which would pass either way.

One cheap hardening from a prior review: a GatewayOptionsTests case binds
Dashboard:GroupToTag through a real ConfigurationBuilder and looks the group up
mis-cased. The property initializer seeds an OrdinalIgnoreCase dictionary, but
only the binder decides whether that instance survives; if it did not, a
mis-cased group name from the directory would grant no tags and the ACL would
deny with no diagnostic.

Docs follow the shipped shape: docs/Sessions.md gains the session-tag model
(owner-key sourced, immutable, visibility-not-access), gateway.md and CLAUDE.md
gain the ACL in their dashboard-auth paragraphs, and three
GatewayDashboardDesign.md passages that still described the ACL as outstanding
now describe both gated seams and the decision order. GatewayConfiguration.md's
ShowTagValues row no longer claims the redaction is the only thing between a
Viewer and another session's values -- it is now the second of two independent
layers. gateway.md's hub-token lifetime corrected 30 minutes -> 5, matching
HubTokenService. Authentication.md disambiguates --dashboard-tags as the only
constraint flag that splits on commas. The plan doc header is Implemented; its
as-built section 12 already existed and is not duplicated.

Verified: NonWindows.slnx builds clean; GatewayOptions/DashboardSessionAcl/
EventsHub filters 37/37; the live-LDAP suite skips cleanly without the env var
and runs 7/7 green against the shared GLAuth with it.
2026-08-17 04:48:34 -04:00

430 lines
20 KiB
C#

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
{
/// <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>
/// 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.
/// </summary>
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";
/// <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);
}
/// <summary>
/// Verifies the SEC-25 tag grant end-to-end from a real LDAP bind: <c>gw-viewer</c>'s only
/// group (GwReader) is mapped to the <c>team-a</c> visibility tag by <c>Dashboard:GroupToTag</c>,
/// and the principal that bind produces is admitted by <see cref="IDashboardSessionAcl"/> for a
/// <c>team-a</c>-tagged session but refused for a <c>team-b</c>-tagged one.
/// </summary>
/// <remarks>
/// 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 <c>GroupToTag</c> map. What only a live bind can prove is that the group
/// names <c>ILdapAuthService</c> actually returns from the shared directory (short RDN values,
/// not DNs) are the ones <c>GroupToTag</c> keys match, which a fabricated principal cannot show.
/// The denial half is the load-bearing assertion: before the ACL, every Viewer saw every session.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[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));
}
/// <summary>
/// Verifies that <c>multi-role</c> — an Administrator in the shared directory — reaches every
/// session regardless of tags.
/// </summary>
/// <remarks>
/// The bypass is proved by the two sessions the account's own grant does <em>not</em> cover.
/// <c>multi-role</c> is a member of GwReader as well as GwAdmin, so this fixture's
/// <c>GroupToTag</c> map grants it <c>team-a</c> — the <c>team-a</c> allow would therefore hold
/// even with the bypass removed and proves nothing on its own. <c>team-b</c> (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.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
[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<DashboardAuthenticator>.Instance);
}
/// <summary>
/// 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.
/// </summary>
private static DashboardOptions AdminOnlyDashboardOptions() => new()
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
},
};
/// <summary>
/// The SEC-25 fixture map: GwReader is admitted as a Viewer and granted <c>team-a</c>. Both keys
/// name groups that already exist in the shared directory — the tag layer is config-only.
/// </summary>
private static DashboardOptions TaggedDashboardOptions() => new()
{
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GwAdmin"] = DashboardRoles.Admin,
["GwReader"] = DashboardRoles.Viewer,
},
GroupToTag = new Dictionary<string, string[]>(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);
/// <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;
}
/// <summary>
/// Registry double serving a fixed set of sessions. The ACL only ever calls
/// <see cref="TryGetSession"/>; the remaining members exist to satisfy the interface and are
/// never reached by these tests.
/// </summary>
/// <param name="sessions">The sessions this registry resolves.</param>
private sealed class FixedSessionManager(IReadOnlyList<GatewaySession> sessions) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessions.FirstOrDefault(candidate => candidate.SessionId == sessionId);
return session is not null;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}