From 792e3f9445e8703b72c2e6e2c0605481fc3b9605 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:31:00 -0400 Subject: [PATCH] feat(auth): add IGroupRoleMapper seam (Task 1.1) --- .../Dashboard/DashboardAuthenticator.cs | 44 +------- .../Dashboard/DashboardGroupRoleMapper.cs | 31 ++++++ .../Dashboard/DashboardGroupRoleMapping.cs | 66 +++++++++++ .../DashboardServiceCollectionExtensions.cs | 2 + .../ZB.MOM.WW.MxGateway.Server.csproj | 1 + .../DashboardGroupRoleMapperTests.cs | 104 ++++++++++++++++++ 6 files changed, 208 insertions(+), 40 deletions(-) create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapper.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardGroupRoleMapping.cs create mode 100644 src/ZB.MOM.WW.MxGateway.Tests/Gateway/Dashboard/DashboardGroupRoleMapperTests.cs 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)]); + } +}