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
|
/// 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
|
/// multiple roles; Admin and Viewer are the only legal values. Returns
|
||||||
/// an empty list when no group matches (caller rejects the login).
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
/// <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>
|
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||||
IEnumerable<string> groups,
|
IEnumerable<string> groups,
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
IReadOnlyDictionary<string, string> groupToRole)
|
||||||
{
|
=> DashboardGroupRoleMapping.MapGroupsToRoles(groups, 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>
|
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||||
{
|
=> DashboardGroupRoleMapping.ExtractFirstRdnValue(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..];
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Task BindServiceAccountAsync(
|
private static Task BindServiceAccountAsync(
|
||||||
LdapConnection connection,
|
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.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||||
@@ -20,6 +21,7 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||||
|
services.AddSingleton<IGroupRoleMapper<string>, DashboardGroupRoleMapper>();
|
||||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||||
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
<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.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" />
|
||||||
|
|||||||
@@ -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