fix(auth): MxGateway 1.2 review fixes — group-claim doc, dedup LdapOptions, 0.1.1 pin

This commit is contained in:
Joseph Doherty
2026-06-02 01:28:57 -04:00
parent c3b466e13d
commit f4dc11bae4
5 changed files with 83 additions and 10 deletions
@@ -2,6 +2,31 @@ using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary>
/// Gateway-side view of the <c>MxGateway:Ldap</c> section. This is a SHADOW of the
/// shared <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> type and is NOT
/// used to perform LDAP authentication at runtime — runtime bind/search is done by the
/// shared <c>ZB.MOM.WW.Auth.Ldap</c> provider, whose options are bound directly from the
/// same <c>MxGateway:Ldap</c> section by <c>AddZbLdapAuth</c> (see
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardServiceCollectionExtensions"/>).
/// <para>
/// This shadow exists for three things only: (1) startup validation via
/// <see cref="GatewayOptionsValidator"/>; (2) the redacted effective-config display
/// (<see cref="EffectiveLdapConfiguration"/> / <see cref="GatewayConfigurationProvider"/>);
/// and (3) it is the single home of the gateway's dev/default LDAP values, which the
/// integration live-test helper copies onto the shared options.
/// </para>
/// <para>
/// Review C2 — DRIFT WARNING: this class MUST stay field-compatible with the shared
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions"/> so the one config section
/// binds cleanly onto both. The two are intentionally NOT merged because their defaults
/// differ on purpose: this shadow ships dev-friendly defaults (plaintext localhost,
/// <c>AllowInsecure=true</c>, populated <c>SearchBase</c>/<c>ServiceAccount*</c>), whereas
/// the shared type is secure-by-default (<c>Transport=Ldaps</c>, <c>AllowInsecure=false</c>,
/// empty DN fields). If you add/rename/remove a field on the shared type, mirror it here
/// (and in the validator + effective-config) so the section keeps binding to both.
/// </para>
/// </summary>
public sealed class LdapOptions
{
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
@@ -72,6 +72,23 @@ public sealed class DashboardAuthenticator(
roles));
}
/// <summary>
/// Builds the dashboard <see cref="ClaimsPrincipal"/> from the LDAP outcome.
/// </summary>
/// <param name="username">The (trimmed) login name → <see cref="ClaimTypes.NameIdentifier"/>.</param>
/// <param name="displayName">The user's display name → <see cref="ClaimTypes.Name"/>.</param>
/// <param name="groups">
/// The user's LDAP groups, as returned by <see cref="ILdapAuthService"/>. NOTE
/// (review C1): these are <b>already-normalized short RDN names</b> (e.g.
/// <c>GwAdmin</c>), not raw distinguished names. The shared
/// <c>ZB.MOM.WW.Auth.Ldap</c> provider strips each group DN to its first RDN
/// value before returning it, so the <see cref="DashboardAuthenticationDefaults.LdapGroupClaimType"/>
/// claim carries the short name. This differs from the pre-cutover behaviour,
/// which surfaced the raw <c>memberOf</c> values (full DNs) on the claim; the
/// claim is informational only (no policy or UI reads its value — authorization
/// is role-based), so the shape change is non-breaking for dashboard consumers.
/// </param>
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
private static ClaimsPrincipal CreatePrincipal(
string username,
string displayName,
@@ -85,6 +102,8 @@ public sealed class DashboardAuthenticator(
];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
// Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN.
claims.AddRange(groups.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType,
group)));
@@ -36,6 +36,19 @@ internal static class DashboardGroupRoleMapping
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
// "GwAdmin" and "gwadmin" both match.
//
// Review C1: with the shared ZB.MOM.WW.Auth.Ldap provider, groups
// arrive here already stripped to short RDN names (the library calls
// FirstRdnValue before returning them). So through the live login path
// the full-string branch only ever sees short names and the RDN
// fallback is effectively a no-op — they collapse to the same key.
// The fallback is retained because this mapping is also reachable
// directly via the IGroupRoleMapper<string> seam (DashboardGroupRoleMapper),
// where a caller could still pass a full DN. CONSEQUENCE: configuring a
// full-DN GroupToRole *key* (e.g. "ou=GwAdmin,ou=groups,...") is
// UNSUPPORTED with the shared library — the incoming group is a short
// name, so it will never equal a full-DN key. Keep GroupToRole keys as
// short group names.
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
{
@@ -6,9 +6,9 @@
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.1" />
<PackageReference Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
@@ -81,9 +81,10 @@ public sealed class DashboardAuthenticatorTests
}
/// <summary>
/// On success the principal carries the resolved roles, the raw LDAP-group claims,
/// the display name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier),
/// under the dashboard authentication scheme — the exact shape produced before the cutover.
/// On success the principal carries the resolved roles, the LDAP-group claims
/// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
/// dashboard authentication scheme.
/// </summary>
[Fact]
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
@@ -115,7 +116,8 @@ public sealed class DashboardAuthenticatorTests
Assert.True(principal.IsInRole(DashboardRoles.Admin));
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
// Raw LDAP groups are surfaced under the dedicated group claim type.
// LDAP groups (already short RDN names from the service) are surfaced under
// the dedicated group claim type.
IReadOnlyList<string> groupClaims = principal.FindAll(
DashboardAuthenticationDefaults.LdapGroupClaimType)
.Select(claim => claim.Value)
@@ -146,11 +148,22 @@ public sealed class DashboardAuthenticatorTests
}
/// <summary>
/// A group supplied as a full distinguished name still resolves to its role via the
/// mapper's leading-RDN fallback, and the original DN is preserved on the group claim.
/// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
/// implementation hands the authenticator a full distinguished name as a group, the
/// mapper's leading-RDN fallback still resolves the role, and whatever string the
/// service supplied is surfaced verbatim on the group claim.
/// <para>
/// This is NOT the real production flow. The shared <c>ZB.MOM.WW.Auth.Ldap</c>
/// provider strips each group DN to its short RDN name before returning it, so the
/// authenticator never receives a full DN from the real library and the group claim
/// in production carries the short name (e.g. <c>GwAdmin</c>), not the DN. This test
/// uses a fake service to exercise only the authenticator's own pass-through of group
/// values; see <see cref="AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims"/>
/// for the realistic (already-short) group shape.
/// </para>
/// </summary>
[Fact]
public async Task AuthenticateAsync_GroupAsDistinguishedName_ResolvesRoleAndPreservesGroupClaim()
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
{
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
@@ -166,7 +179,10 @@ public sealed class DashboardAuthenticatorTests
Assert.True(result.Succeeded);
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
// Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
Assert.True(principal.IsInRole(DashboardRoles.Admin));
// The authenticator surfaces the value the (fake) service returned, verbatim.
// With the real library this value would already be the short RDN ("GwAdmin").
Assert.Contains(
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
claim => claim.Value == groupDn);