test(dashboard)+docs: SEC-25 live-LDAP ACL coverage; design marked implemented
The per-session dashboard event ACL shipped in693a78d+7ec0b35with 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.
This commit is contained in:
@@ -1,11 +1,14 @@
|
||||
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;
|
||||
@@ -23,6 +26,18 @@ public sealed class DashboardLdapLiveTests
|
||||
/// </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.
|
||||
@@ -152,20 +167,93 @@ public sealed class DashboardLdapLiveTests
|
||||
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)
|
||||
private static DashboardAuthenticator CreateAuthenticator(LibraryLdapOptions ldapOptions) =>
|
||||
CreateAuthenticator(ldapOptions, AdminOnlyDashboardOptions());
|
||||
|
||||
private static DashboardAuthenticator CreateAuthenticator(
|
||||
LibraryLdapOptions ldapOptions,
|
||||
DashboardOptions dashboardOptions)
|
||||
{
|
||||
GatewayOptions gatewayOptions = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToRole = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
},
|
||||
},
|
||||
};
|
||||
GatewayOptions gatewayOptions = new() { Dashboard = dashboardOptions };
|
||||
|
||||
return new DashboardAuthenticator(
|
||||
new LdapAuthService(ldapOptions),
|
||||
@@ -174,6 +262,67 @@ public sealed class DashboardLdapLiveTests
|
||||
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
|
||||
@@ -228,4 +377,53 @@ public sealed class DashboardLdapLiveTests
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,33 @@ public sealed class GatewayOptionsTests
|
||||
Assert.Null(new DashboardOptions().AutoLoginUser);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>Dashboard:GroupToTag</c> keeps its ordinal-ignore-case group lookup after
|
||||
/// configuration binding, and that <c>UntaggedSessionVisibility</c> binds from its string form.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The property initializer seeds the dictionary with <see cref="StringComparer.OrdinalIgnoreCase"/>,
|
||||
/// but only the binder decides whether that instance is populated in place or replaced by a
|
||||
/// default-comparer one. Asserting the comparer on a hand-constructed <see cref="DashboardOptions"/>
|
||||
/// would prove nothing about the configured path; a mis-cased LDAP group name from the directory
|
||||
/// would then silently grant no tags, and the SEC-25 ACL would deny with no diagnostic.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DashboardOptions_GroupToTag_BindsCaseInsensitively()
|
||||
{
|
||||
GatewayOptions options = BindOptions(new Dictionary<string, string?>
|
||||
{
|
||||
["MxGateway:Dashboard:GroupToTag:GwReader:0"] = "team-a",
|
||||
["MxGateway:Dashboard:GroupToTag:GwReader:1"] = "team-b",
|
||||
["MxGateway:Dashboard:UntaggedSessionVisibility"] = "AllViewers",
|
||||
["MxGateway:Ldap:ServiceAccountPassword"] = "test-bind-password",
|
||||
});
|
||||
|
||||
Assert.True(options.Dashboard.GroupToTag.TryGetValue("gwREADER", out string[]? tags));
|
||||
Assert.Equal(["team-a", "team-b"], tags);
|
||||
Assert.Equal(UntaggedSessionVisibility.AllViewers, options.Dashboard.UntaggedSessionVisibility);
|
||||
}
|
||||
|
||||
private static GatewayOptions BindOptions(IReadOnlyDictionary<string, string?> configurationValues)
|
||||
{
|
||||
using ServiceProvider services = BuildServices(configurationValues);
|
||||
|
||||
Reference in New Issue
Block a user