fix(auth): MxGateway 1.2 review fixes — group-claim doc, dedup LdapOptions, 0.1.1 pin
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user