f40409a688
Tracking IDs are review bookkeeping that rots in shipped source; the rationale they tagged is kept. Comment-only change (CommentChecker 458 -> 16 false positives).
430 lines
20 KiB
C#
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 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 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 tagged 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;
|
|
}
|
|
}
|