feat(auth): add IGroupRoleMapper<string> seam (Task 1.1)
This commit is contained in:
@@ -142,56 +142,20 @@ public sealed class DashboardAuthenticator(
|
||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||
/// an empty list when no group matches (caller rejects the login).
|
||||
/// Delegates to <see cref="DashboardGroupRoleMapping"/>, the single source
|
||||
/// of truth shared with <see cref="DashboardGroupRoleMapper"/>.
|
||||
/// </summary>
|
||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||
IEnumerable<string> groups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
if (groupToRole.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
|
||||
// Lookup precedence (Server-040): the full literal group string is
|
||||
// tried first; only if that misses do we fall back to the leading
|
||||
// RDN value (e.g. "GwAdmin" extracted from
|
||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
||||
// "GwAdmin" and "gwadmin" both match.
|
||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||
{
|
||||
roles.Add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. roles];
|
||||
}
|
||||
=> DashboardGroupRoleMapping.MapGroupsToRoles(groups, groupToRole);
|
||||
|
||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
return distinguishedName;
|
||||
}
|
||||
|
||||
int valueStart = equalsIndex + 1;
|
||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||
|
||||
return commaIndex > valueStart
|
||||
? distinguishedName[valueStart..commaIndex]
|
||||
: distinguishedName[valueStart..];
|
||||
}
|
||||
=> DashboardGroupRoleMapping.ExtractFirstRdnValue(distinguishedName);
|
||||
|
||||
private static Task BindServiceAccountAsync(
|
||||
LdapConnection connection,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Shared-Auth <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
|
||||
/// LDAP-group → role mapping. Roles are plain strings
|
||||
/// (<see cref="DashboardRoles.Admin"/> / <see cref="DashboardRoles.Viewer"/>),
|
||||
/// so <c>TRole</c> is <see cref="string"/>. The mapping rules (full-DN first,
|
||||
/// leading-RDN fallback, case-insensitive) live in
|
||||
/// <see cref="DashboardGroupRoleMapping"/>, shared with
|
||||
/// <see cref="DashboardAuthenticator"/> so behaviour stays identical.
|
||||
/// </summary>
|
||||
/// <param name="options">Gateway options supplying the dashboard GroupToRole map.</param>
|
||||
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
|
||||
: IGroupRoleMapper<string>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public Task<GroupRoleMapping<string>> MapAsync(
|
||||
IReadOnlyList<string> groups,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<string> roles = DashboardGroupRoleMapping.MapGroupsToRoles(
|
||||
groups,
|
||||
options.Value.Dashboard.GroupToRole);
|
||||
|
||||
return Task.FromResult(new GroupRoleMapping<string>(roles, Scope: null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for mapping a user's LDAP groups to dashboard roles.
|
||||
/// Both <see cref="DashboardAuthenticator"/> (the existing login flow) and
|
||||
/// <see cref="DashboardGroupRoleMapper"/> (the shared-Auth
|
||||
/// <see cref="ZB.MOM.WW.Auth.Abstractions.Roles.IGroupRoleMapper{TRole}"/> seam)
|
||||
/// delegate here so the precedence and case rules stay identical.
|
||||
/// </summary>
|
||||
internal static class DashboardGroupRoleMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps the user's LDAP groups to dashboard roles. A user can pick up
|
||||
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||
/// an empty list when no group matches (caller rejects the login).
|
||||
/// </summary>
|
||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||
IEnumerable<string> groups,
|
||||
IReadOnlyDictionary<string, string> groupToRole)
|
||||
{
|
||||
if (groupToRole.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
HashSet<string> roles = new(StringComparer.Ordinal);
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
|
||||
// Lookup precedence (Server-040): the full literal group string is
|
||||
// tried first; only if that misses do we fall back to the leading
|
||||
// RDN value (e.g. "GwAdmin" extracted from
|
||||
// "ou=GwAdmin,ou=groups,..."). The map's comparer is
|
||||
// OrdinalIgnoreCase (see DashboardOptions.GroupToRole), so e.g.
|
||||
// "GwAdmin" and "gwadmin" both match.
|
||||
if (groupToRole.TryGetValue(normalizedGroup, out string? mapped)
|
||||
|| groupToRole.TryGetValue(ExtractFirstRdnValue(normalizedGroup), out mapped))
|
||||
{
|
||||
roles.Add(mapped);
|
||||
}
|
||||
}
|
||||
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
return distinguishedName;
|
||||
}
|
||||
|
||||
int valueStart = equalsIndex + 1;
|
||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||
|
||||
return commaIndex > valueStart
|
||||
? distinguishedName[valueStart..commaIndex]
|
||||
: distinguishedName[valueStart..];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
@@ -20,6 +21,7 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<IGroupRoleMapper<string>, DashboardGroupRoleMapper>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
<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.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" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DashboardGroupRoleMapper"/>, the shared-Auth
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/> seam over the dashboard's
|
||||
/// LDAP-group → role mapping. Behaviour must match the existing
|
||||
/// <c>DashboardAuthenticator.MapGroupsToRoles</c> precedence/case rules.
|
||||
/// </summary>
|
||||
public sealed class DashboardGroupRoleMapperTests
|
||||
{
|
||||
private static DashboardGroupRoleMapper CreateMapper(Dictionary<string, string> mapping)
|
||||
{
|
||||
GatewayOptions options = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions
|
||||
{
|
||||
GroupToRole = mapping,
|
||||
},
|
||||
};
|
||||
|
||||
return new DashboardGroupRoleMapper(Options.Create(options));
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["GwAdmin"] = DashboardRoles.Admin,
|
||||
["GwReader"] = DashboardRoles.Viewer,
|
||||
};
|
||||
|
||||
/// <summary>Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.</summary>
|
||||
/// <param name="ldapGroup">The LDAP group name or distinguished name.</param>
|
||||
/// <param name="expectedRole">The expected role or null if no match.</param>
|
||||
[Theory]
|
||||
[InlineData("GwAdmin", DashboardRoles.Admin)]
|
||||
[InlineData("gwadmin", DashboardRoles.Admin)]
|
||||
[InlineData("ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", DashboardRoles.Admin)]
|
||||
[InlineData("OtherGroup", null)]
|
||||
public async Task MapAsync_ResolvesByShortNameAndDistinguishedName(
|
||||
string ldapGroup,
|
||||
string? expectedRole)
|
||||
{
|
||||
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
|
||||
|
||||
GroupRoleMapping<string> result = await mapper.MapAsync([ldapGroup], CancellationToken.None);
|
||||
|
||||
if (expectedRole is null)
|
||||
{
|
||||
Assert.Empty(result.Roles);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.Equal(expectedRole, Assert.Single(result.Roles));
|
||||
}
|
||||
|
||||
Assert.Null(result.Scope);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that admin and viewer roles are both emitted when both groups are present.</summary>
|
||||
[Fact]
|
||||
public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
|
||||
{
|
||||
DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
|
||||
|
||||
GroupRoleMapping<string> result = await mapper.MapAsync(
|
||||
["GwAdmin", "GwReader"],
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Contains(DashboardRoles.Admin, result.Roles);
|
||||
Assert.Contains(DashboardRoles.Viewer, result.Roles);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that an empty GroupToRole map yields no roles.</summary>
|
||||
[Fact]
|
||||
public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
|
||||
{
|
||||
DashboardGroupRoleMapper mapper = CreateMapper(new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
GroupRoleMapping<string> result = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
|
||||
|
||||
Assert.Empty(result.Roles);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the extracted shared helper is the single source of truth: it
|
||||
/// produces the same roles the mapper does for the same inputs.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task SharedHelper_MatchesMapperOutput()
|
||||
{
|
||||
Dictionary<string, string> mapping = StandardMapping();
|
||||
DashboardGroupRoleMapper mapper = CreateMapper(mapping);
|
||||
string[] groups = ["ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", "gwreader"];
|
||||
|
||||
IReadOnlyList<string> helperRoles = DashboardGroupRoleMapping.MapGroupsToRoles(groups, mapping);
|
||||
GroupRoleMapping<string> mapperResult = await mapper.MapAsync(groups, CancellationToken.None);
|
||||
|
||||
Assert.Equal([.. helperRoles.OrderBy(r => r)], [.. mapperResult.Roles.OrderBy(r => r)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user