diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
index 8df57fd..ff0b11b 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardAuthenticator.cs
@@ -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 , the single source
+ /// of truth shared with .
///
/// The collection of LDAP groups the user belongs to.
/// The mapping from group names to dashboard role names.
internal static IReadOnlyList MapGroupsToRoles(
IEnumerable groups,
IReadOnlyDictionary groupToRole)
- {
- if (groupToRole.Count == 0)
- {
- return [];
- }
-
- HashSet 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);
/// Extracts the first RDN value from a distinguished name.
/// The LDAP distinguished name.
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,
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapper.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapper.cs
new file mode 100644
index 0000000..946c7a1
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapper.cs
@@ -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;
+
+///
+/// Shared-Auth seam over the dashboard's
+/// LDAP-group → role mapping. Roles are plain strings
+/// ( / ),
+/// so TRole is . The mapping rules (full-DN first,
+/// leading-RDN fallback, case-insensitive) live in
+/// , shared with
+/// so behaviour stays identical.
+///
+/// Gateway options supplying the dashboard GroupToRole map.
+public sealed class DashboardGroupRoleMapper(IOptions options)
+ : IGroupRoleMapper
+{
+ ///
+ public Task> MapAsync(
+ IReadOnlyList groups,
+ CancellationToken ct)
+ {
+ IReadOnlyList roles = DashboardGroupRoleMapping.MapGroupsToRoles(
+ groups,
+ options.Value.Dashboard.GroupToRole);
+
+ return Task.FromResult(new GroupRoleMapping(roles, Scope: null));
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapping.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapping.cs
new file mode 100644
index 0000000..f02b9b7
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapping.cs
@@ -0,0 +1,66 @@
+namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
+
+///
+/// Single source of truth for mapping a user's LDAP groups to dashboard roles.
+/// Both (the existing login flow) and
+/// (the shared-Auth
+/// seam)
+/// delegate here so the precedence and case rules stay identical.
+///
+internal static class DashboardGroupRoleMapping
+{
+ ///
+ /// 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).
+ ///
+ /// The collection of LDAP groups the user belongs to.
+ /// The mapping from group names to dashboard role names.
+ internal static IReadOnlyList MapGroupsToRoles(
+ IEnumerable groups,
+ IReadOnlyDictionary groupToRole)
+ {
+ if (groupToRole.Count == 0)
+ {
+ return [];
+ }
+
+ HashSet 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];
+ }
+
+ /// Extracts the first RDN value from a distinguished name.
+ /// The LDAP distinguished name.
+ 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..];
+ }
+}
diff --git a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
index fc45d3f..3be4d57 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
+++ b/src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardServiceCollectionExtensions.cs
@@ -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();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton, DashboardGroupRoleMapper>();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
index b36e4a2..2c156b7 100644
--- a/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
+++ b/src/ZB.MOM.WW.MxGateway.Server/ZB.MOM.WW.MxGateway.Server.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupRoleMapperTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupRoleMapperTests.cs
new file mode 100644
index 0000000..727e297
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupRoleMapperTests.cs
@@ -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;
+
+///
+/// Tests for , the shared-Auth
+/// seam over the dashboard's
+/// LDAP-group → role mapping. Behaviour must match the existing
+/// DashboardAuthenticator.MapGroupsToRoles precedence/case rules.
+///
+public sealed class DashboardGroupRoleMapperTests
+{
+ private static DashboardGroupRoleMapper CreateMapper(Dictionary mapping)
+ {
+ GatewayOptions options = new()
+ {
+ Dashboard = new DashboardOptions
+ {
+ GroupToRole = mapping,
+ },
+ };
+
+ return new DashboardGroupRoleMapper(Options.Create(options));
+ }
+
+ private static Dictionary StandardMapping() => new(StringComparer.OrdinalIgnoreCase)
+ {
+ ["GwAdmin"] = DashboardRoles.Admin,
+ ["GwReader"] = DashboardRoles.Viewer,
+ };
+
+ /// Verifies full-DN match, leading-RDN fallback, case-insensitivity, and unmapped → empty.
+ /// The LDAP group name or distinguished name.
+ /// The expected role or null if no match.
+ [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 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);
+ }
+
+ /// Verifies that admin and viewer roles are both emitted when both groups are present.
+ [Fact]
+ public async Task MapAsync_AdminPlusViewer_BothRolesEmitted()
+ {
+ DashboardGroupRoleMapper mapper = CreateMapper(StandardMapping());
+
+ GroupRoleMapping result = await mapper.MapAsync(
+ ["GwAdmin", "GwReader"],
+ CancellationToken.None);
+
+ Assert.Contains(DashboardRoles.Admin, result.Roles);
+ Assert.Contains(DashboardRoles.Viewer, result.Roles);
+ }
+
+ /// Verifies that an empty GroupToRole map yields no roles.
+ [Fact]
+ public async Task MapAsync_EmptyMapping_ReturnsNoRoles()
+ {
+ DashboardGroupRoleMapper mapper = CreateMapper(new Dictionary(StringComparer.OrdinalIgnoreCase));
+
+ GroupRoleMapping result = await mapper.MapAsync(["GwAdmin"], CancellationToken.None);
+
+ Assert.Empty(result.Roles);
+ }
+
+ ///
+ /// Verifies the extracted shared helper is the single source of truth: it
+ /// produces the same roles the mapper does for the same inputs.
+ ///
+ [Fact]
+ public async Task SharedHelper_MatchesMapperOutput()
+ {
+ Dictionary mapping = StandardMapping();
+ DashboardGroupRoleMapper mapper = CreateMapper(mapping);
+ string[] groups = ["ou=GwAdmin,ou=groups,dc=lmxopcua,dc=local", "gwreader"];
+
+ IReadOnlyList helperRoles = DashboardGroupRoleMapping.MapGroupsToRoles(groups, mapping);
+ GroupRoleMapping mapperResult = await mapper.MapAsync(groups, CancellationToken.None);
+
+ Assert.Equal([.. helperRoles.OrderBy(r => r)], [.. mapperResult.Roles.OrderBy(r => r)]);
+ }
+}