From 9230afa25f5ab01c52803c97253728f4075ac789 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:30:42 -0400 Subject: [PATCH] feat(auth): add IGroupRoleMapper seam (Task 1.1) --- .../ScadaBridgeGroupRoleMapper.cs | 40 ++++++ .../ServiceCollectionExtensions.cs | 9 ++ .../ZB.MOM.WW.ScadaBridge.Security.csproj | 1 + .../ScadaBridgeGroupRoleMapperTests.cs | 127 ++++++++++++++++++ 4 files changed, 177 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.Security/ScadaBridgeGroupRoleMapper.cs create mode 100644 tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ScadaBridgeGroupRoleMapper.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ScadaBridgeGroupRoleMapper.cs new file mode 100644 index 00000000..02a23ee0 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ScadaBridgeGroupRoleMapper.cs @@ -0,0 +1,40 @@ +using ZB.MOM.WW.Auth.Abstractions.Roles; + +namespace ZB.MOM.WW.ScadaBridge.Security; + +/// +/// Adapts ScadaBridge's DB-backed to the shared +/// seam from ZB.MOM.WW.Auth.Abstractions. +/// +/// +/// Task 1.1 of the Auth-library adoption: this is an additive wrapper. It does not +/// re-implement the LDAP-group → role resolution or the site-scope union semantics — it +/// delegates wholesale to and re-shapes the +/// result onto the shared contract. is +/// because ScadaBridge roles travel as plain strings in claims. The full +/// — including +/// and — is carried verbatim in the +/// mapping's opaque so no site-scope information +/// is lost across the seam. The existing login flow is rewired to consume this in a later task. +/// +public sealed class ScadaBridgeGroupRoleMapper : IGroupRoleMapper +{ + private readonly RoleMapper _roleMapper; + + /// Initializes the mapper with the wrapped . + /// The DB-backed role mapper whose union semantics are reused. + public ScadaBridgeGroupRoleMapper(RoleMapper roleMapper) + { + _roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper)); + } + + /// + public async Task> MapAsync(IReadOnlyList groups, CancellationToken ct) + { + var result = await _roleMapper.MapGroupsToRolesAsync(groups, ct); + + // Carry the full RoleMappingResult as the opaque Scope so the site-scope + // payload (PermittedSiteIds + IsSystemWideDeployment) survives the seam. + return new GroupRoleMapping(result.Roles, Scope: result); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs index 33074a80..97c6f785 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ServiceCollectionExtensions.cs @@ -3,6 +3,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; namespace ZB.MOM.WW.ScadaBridge.Security; @@ -18,6 +19,14 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + // Auth-adoption Task 1.1: register the shared IGroupRoleMapper + // seam additively, wrapping RoleMapper to reuse its DB-backed mapping + + // site-scope union semantics. Scoped to match RoleMapper's lifetime (it + // depends on the Scoped ISecurityRepository). The existing RoleMapper + // registration and its call sites are left untouched — login is rewired + // to consume this seam in a later task. + services.AddScoped, ScadaBridgeGroupRoleMapper>(); + // Security-020: register the IValidateOptions so a // missing/empty LdapServer or LdapSearchBase fails fast at startup // with a clear, key-naming message rather than a generic LDAP error diff --git a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj index dabf2f2c..faa7131e 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj +++ b/src/ZB.MOM.WW.ScadaBridge.Security/ZB.MOM.WW.ScadaBridge.Security.csproj @@ -14,6 +14,7 @@ + diff --git a/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs new file mode 100644 index 00000000..8ae32059 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.Security.Tests/ScadaBridgeGroupRoleMapperTests.cs @@ -0,0 +1,127 @@ +using ZB.MOM.WW.Auth.Abstractions.Roles; +using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security; +using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; +using ZB.MOM.WW.ScadaBridge.Security; + +namespace ZB.MOM.WW.ScadaBridge.Security.Tests; + +#region Task 1.1: ScadaBridgeGroupRoleMapper (IGroupRoleMapper seam) + +public class ScadaBridgeGroupRoleMapperTests +{ + // A minimal in-memory ISecurityRepository so the seam test needs no database. + // Only the two reads RoleMapper.MapGroupsToRolesAsync uses are implemented; + // the rest throw to make any accidental dependency on them obvious. + private sealed class FakeSecurityRepository : ISecurityRepository + { + private readonly IReadOnlyList _mappings; + private readonly IReadOnlyDictionary> _scopeRules; + + public FakeSecurityRepository( + IReadOnlyList mappings, + IReadOnlyDictionary> scopeRules) + { + _mappings = mappings; + _scopeRules = scopeRules; + } + + public Task> GetAllMappingsAsync(CancellationToken cancellationToken = default) + => Task.FromResult(_mappings); + + public Task> GetScopeRulesForMappingAsync(int ldapGroupMappingId, CancellationToken cancellationToken = default) + => Task.FromResult(_scopeRules.TryGetValue(ldapGroupMappingId, out var rules) + ? rules + : (IReadOnlyList)Array.Empty()); + + public Task GetMappingByIdAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task> GetMappingsByRoleAsync(string role, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task AddMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task UpdateMappingAsync(LdapGroupMapping mapping, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task DeleteMappingAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task GetScopeRuleByIdAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task AddScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task UpdateScopeRuleAsync(SiteScopeRule rule, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task DeleteScopeRuleAsync(int id, CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + public Task SaveChangesAsync(CancellationToken cancellationToken = default) + => throw new NotSupportedException(); + } + + private static LdapGroupMapping Mapping(int id, string group, string role) + { + var m = new LdapGroupMapping(group, role); + m.Id = id; + return m; + } + + [Fact] + public async Task MapAsync_ReturnsSameRolesAsRoleMapper_AndCarriesResultInScope_SiteScoped() + { + // Two matched mappings: an Admin group and a site-scoped Deployment group. + var mappings = new List + { + Mapping(1, "SCADA-Admins", Roles.Admin), + Mapping(2, "SiteDeployers", Roles.Deployment), + }; + var scopeRules = new Dictionary> + { + [2] = new[] + { + new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 11 }, + new SiteScopeRule { LdapGroupMappingId = 2, SiteId = 22 }, + }, + }; + var repo = new FakeSecurityRepository(mappings, scopeRules); + var roleMapper = new RoleMapper(repo); + var sut = new ScadaBridgeGroupRoleMapper(roleMapper); + + var groups = new[] { "SCADA-Admins", "SiteDeployers" }; + + // The wrapped RoleMapper result is the oracle the seam must preserve. + var expected = await roleMapper.MapGroupsToRolesAsync(groups, CancellationToken.None); + var mapping = await sut.MapAsync(groups, CancellationToken.None); + + // Roles: same set as RoleMapper. + Assert.Equal(expected.Roles.OrderBy(r => r), mapping.Roles.OrderBy(r => r)); + Assert.Contains(Roles.Admin, mapping.Roles); + Assert.Contains(Roles.Deployment, mapping.Roles); + + // Scope: carries the full RoleMappingResult (no site-scope info lost). + var scope = Assert.IsType(mapping.Scope); + Assert.False(scope.IsSystemWideDeployment); + Assert.Contains("11", scope.PermittedSiteIds); + Assert.Contains("22", scope.PermittedSiteIds); + Assert.Equal(expected.PermittedSiteIds.OrderBy(s => s), scope.PermittedSiteIds.OrderBy(s => s)); + Assert.Equal(expected.IsSystemWideDeployment, scope.IsSystemWideDeployment); + } + + [Fact] + public async Task MapAsync_PreservesSystemWideDeploymentFlagInScope() + { + // Unscoped Deployment mapping -> system-wide, empty PermittedSiteIds. + var mappings = new List + { + Mapping(1, "GlobalDeployers", Roles.Deployment), + }; + var repo = new FakeSecurityRepository(mappings, new Dictionary>()); + var roleMapper = new RoleMapper(repo); + var sut = new ScadaBridgeGroupRoleMapper(roleMapper); + + var mapping = await sut.MapAsync(new[] { "GlobalDeployers" }, CancellationToken.None); + + Assert.Contains(Roles.Deployment, mapping.Roles); + var scope = Assert.IsType(mapping.Scope); + Assert.True(scope.IsSystemWideDeployment); + Assert.Empty(scope.PermittedSiteIds); + } +} + +#endregion