From 653487547687f08742f4408b0a28b9d700209ea3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:29:45 -0400 Subject: [PATCH] feat(auth): add IGroupRoleMapper seam (Task 1.1) --- .../Ldap/OtOpcUaGroupRoleMapper.cs | 34 +++++ .../ServiceCollectionExtensions.cs | 7 ++ .../ZB.MOM.WW.OtOpcUa.Security.csproj | 1 + .../OtOpcUaGroupRoleMapperTests.cs | 118 ++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaGroupRoleMapper.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaGroupRoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaGroupRoleMapper.cs new file mode 100644 index 00000000..d8500c5e --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaGroupRoleMapper.cs @@ -0,0 +1,34 @@ +using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; + +namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; + +/// +/// OtOpcUa's implementation (roles are plain strings, +/// so TRole = string). A thin, behaviour-preserving adapter over the existing +/// : it computes the appsettings baseline via +/// , then unions in system-wide DB grants via +/// . The OtOpcUa authz model is global (no per-cluster scope at +/// login), so is always null. +/// +/// +/// This is the shared-library seam introduced ahead of rewiring the login flow; it does not +/// duplicate mapping logic and does not change behaviour. See scadaproj/components/auth. +/// +public sealed class OtOpcUaGroupRoleMapper( + IOptions ldapOptions, + ILdapGroupRoleMappingService dbMappings) : IGroupRoleMapper +{ + /// + public async Task> MapAsync(IReadOnlyList groups, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(groups); + + var baseline = RoleMapper.Map(groups, ldapOptions.Value.GroupToRole); + var dbRows = await dbMappings.GetByGroupsAsync(groups, ct).ConfigureAwait(false); + var merged = RoleMapper.Merge(baseline, dbRows); + + return new GroupRoleMapping(merged, Scope: null); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index fa715648..fbf412ff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -7,6 +7,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Security.Jwt; using ZB.MOM.WW.OtOpcUa.Security.Ldap; @@ -41,6 +42,12 @@ public static class ServiceCollectionExtensions // driver path) ends up with exactly one descriptor regardless of registration order. services.TryAddSingleton(); + // Shared ZB.MOM.WW.Auth group→role mapper seam (Task 1.1, additive). Wraps the existing + // RoleMapper.Map + RoleMapper.Merge logic; the login flow is rewired to consume it in a + // later task. Scoped to match ILdapGroupRoleMappingService (DbContext-backed, registered + // Scoped) — a singleton here would capture the scoped DB service. + services.TryAddScoped, OtOpcUaGroupRoleMapper>(); + services.AddDataProtection() .PersistKeysToDbContext() .SetApplicationName("OtOpcUa"); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj index 1207088b..b33345ba 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ZB.MOM.WW.OtOpcUa.Security.csproj @@ -13,6 +13,7 @@ + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs new file mode 100644 index 00000000..705dfb19 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs @@ -0,0 +1,118 @@ +using Microsoft.Extensions.Options; +using Shouldly; +using Xunit; +using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; +using ZB.MOM.WW.OtOpcUa.Security.Ldap; + +namespace ZB.MOM.WW.OtOpcUa.Security.Tests; + +/// +/// Proves is a behaviour-preserving wrapper over the +/// existing + logic: config +/// baseline + system-wide DB grants, cluster-scoped DB rows ignored, unmapped groups dropped, +/// and Scope always null. +/// +public sealed class OtOpcUaGroupRoleMapperTests +{ + private static OtOpcUaGroupRoleMapper Build( + IDictionary groupToRole, + params LdapGroupRoleMapping[] dbRows) + { + var options = Microsoft.Extensions.Options.Options.Create(new LdapOptions + { + GroupToRole = new Dictionary(groupToRole, StringComparer.OrdinalIgnoreCase), + }); + return new OtOpcUaGroupRoleMapper(options, new FakeMappingService(dbRows)); + } + + [Fact] + public async Task Maps_config_group_and_drops_unmapped_group() + { + var mapper = Build(new Dictionary { ["AdminGroup"] = "FleetAdmin" }); + + var result = await mapper.MapAsync(["AdminGroup", "UnmappedGroup"], CancellationToken.None); + + result.Roles.ShouldBe(["FleetAdmin"]); + result.Scope.ShouldBeNull(); + } + + [Fact] + public async Task System_wide_db_row_adds_role_on_top_of_config_baseline() + { + var mapper = Build( + new Dictionary { ["viewers"] = "ConfigViewer" }, + new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true }); + + var result = await mapper.MapAsync(["viewers", "admins"], CancellationToken.None); + + result.Roles.ShouldContain("ConfigViewer"); + result.Roles.ShouldContain("FleetAdmin"); + result.Scope.ShouldBeNull(); + } + + [Fact] + public async Task Cluster_scoped_db_row_is_ignored() + { + var mapper = Build( + new Dictionary(), + new LdapGroupRoleMapping + { + LdapGroup = "site-a-editors", + Role = AdminRole.ConfigEditor, + IsSystemWide = false, + ClusterId = "SITE-A", + }); + + var result = await mapper.MapAsync(["site-a-editors"], CancellationToken.None); + + result.Roles.ShouldNotContain("ConfigEditor"); + result.Roles.ShouldBeEmpty(); + } + + [Fact] + public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs() + { + var groupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["viewers"] = "ConfigViewer", + ["editors"] = "ConfigEditor", + }; + var dbRows = new[] + { + new LdapGroupRoleMapping { LdapGroup = "admins", Role = AdminRole.FleetAdmin, IsSystemWide = true }, + new LdapGroupRoleMapping { LdapGroup = "site-a", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" }, + }; + var groups = new[] { "viewers", "editors", "admins", "site-a", "noise" }; + + var mapper = Build(groupToRole, dbRows); + + // Oracle: exactly what the legacy login path computes today. + var baseline = RoleMapper.Map(groups, groupToRole); + var expected = RoleMapper.Merge(baseline, dbRows); + + var result = await mapper.MapAsync(groups, CancellationToken.None); + + result.Roles.OrderBy(r => r).ShouldBe(expected.OrderBy(r => r)); + result.Scope.ShouldBeNull(); + } + + /// In-memory stand-in for the EF-backed DB service; returns the configured rows verbatim. + private sealed class FakeMappingService(IReadOnlyList rows) : ILdapGroupRoleMappingService + { + public Task> GetByGroupsAsync( + IEnumerable ldapGroups, CancellationToken cancellationToken) + => Task.FromResult(rows); + + public Task> ListAllAsync(CancellationToken cancellationToken) + => Task.FromResult(rows); + + public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + } +}