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;
|
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
|
public sealed class LdapOptions
|
||||||
{
|
{
|
||||||
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
/// <summary>Gets a value indicating whether LDAP authentication is enabled.</summary>
|
||||||
|
|||||||
@@ -72,6 +72,23 @@ public sealed class DashboardAuthenticator(
|
|||||||
roles));
|
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(
|
private static ClaimsPrincipal CreatePrincipal(
|
||||||
string username,
|
string username,
|
||||||
string displayName,
|
string displayName,
|
||||||
@@ -85,6 +102,8 @@ public sealed class DashboardAuthenticator(
|
|||||||
];
|
];
|
||||||
|
|
||||||
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
|
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(
|
claims.AddRange(groups.Select(group => new Claim(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||||
group)));
|
group)));
|
||||||
|
|||||||
@@ -36,6 +36,19 @@ internal static class DashboardGroupRoleMapping
|
|||||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
||||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
||||||
// "GwAdmin" and "gwadmin" both match.
|
// "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)
|
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
|
<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.Abstractions" Version="0.1.1" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Auth.Ldap" Version="0.1.1" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.1.0" />
|
<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.Configuration" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Health" Version="0.1.0" />
|
||||||
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
<PackageReference Include="ZB.MOM.WW.Telemetry" Version="0.1.0" />
|
||||||
|
|||||||
@@ -81,9 +81,10 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// On success the principal carries the resolved roles, the raw LDAP-group claims,
|
/// On success the principal carries the resolved roles, the LDAP-group claims
|
||||||
/// the display name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier),
|
/// (short RDN names as returned by <see cref="ILdapAuthService"/>), the display
|
||||||
/// under the dashboard authentication scheme — the exact shape produced before the cutover.
|
/// name (ClaimTypes.Name), and the username (ClaimTypes.NameIdentifier), under the
|
||||||
|
/// dashboard authentication scheme.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AuthenticateAsync_Success_BuildsPrincipalWithExpectedClaims()
|
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.Admin));
|
||||||
Assert.True(principal.IsInRole(DashboardRoles.Viewer));
|
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(
|
IReadOnlyList<string> groupClaims = principal.FindAll(
|
||||||
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||||
.Select(claim => claim.Value)
|
.Select(claim => claim.Value)
|
||||||
@@ -146,11 +148,22 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A group supplied as a full distinguished name still resolves to its role via the
|
/// Direct-injection path only (review C1): when an <see cref="ILdapAuthService"/>
|
||||||
/// mapper's leading-RDN fallback, and the original DN is preserved on the group claim.
|
/// 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>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AuthenticateAsync_GroupAsDistinguishedName_ResolvesRoleAndPreservesGroupClaim()
|
public async Task AuthenticateAsync_GroupAsDistinguishedNameFromService_ResolvesRoleAndSurfacesServiceValue()
|
||||||
{
|
{
|
||||||
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
|
const string groupDn = "ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local";
|
||||||
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
FakeLdapAuthService ldap = new(LdapAuthResult.Success(
|
||||||
@@ -166,7 +179,10 @@ public sealed class DashboardAuthenticatorTests
|
|||||||
|
|
||||||
Assert.True(result.Succeeded);
|
Assert.True(result.Succeeded);
|
||||||
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
ClaimsPrincipal principal = Assert.IsType<ClaimsPrincipal>(result.Principal);
|
||||||
|
// Role resolves via the leading-RDN fallback in DashboardGroupRoleMapping.
|
||||||
Assert.True(principal.IsInRole(DashboardRoles.Admin));
|
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(
|
Assert.Contains(
|
||||||
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
principal.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType),
|
||||||
claim => claim.Value == groupDn);
|
claim => claim.Value == groupDn);
|
||||||
|
|||||||
Reference in New Issue
Block a user