From b7191940468a3a06da40f3de36894172a03e3f47 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Fri, 29 May 2026 09:43:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(security):=20RoleMapper.Merge=20=E2=80=94?= =?UTF-8?q?=20additive=20DB-backed=20role=20grants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Ldap/RoleMapper.cs | 19 ++++++++++++++++++ .../RoleMapperTests.cs | 20 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs index 3eb7e690..8e0716e6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/RoleMapper.cs @@ -1,3 +1,5 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; + namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; /// @@ -24,4 +26,21 @@ public static class RoleMapper } return [.. roles]; } + + /// + /// Merge the appsettings-derived baseline roles with system-wide DB grants. DB rows are + /// additive; cluster-scoped rows (IsSystemWide == false) are ignored under the global model. + /// + /// Roles already resolved from appsettings (or the dev stub). + /// LdapGroupRoleMapping rows for the user's groups (from GetByGroupsAsync). + public static IReadOnlyList Merge( + IReadOnlyCollection baselineRoles, + IReadOnlyCollection dbRows) + { + var roles = new HashSet(baselineRoles, StringComparer.OrdinalIgnoreCase); + foreach (var row in dbRows) + if (row.IsSystemWide) + roles.Add(row.Role.ToString()); + return [.. roles]; + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs index 9af7aba1..b94a445e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/RoleMapperTests.cs @@ -1,5 +1,7 @@ using Shouldly; using Xunit; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; using ZB.MOM.WW.OtOpcUa.Security.Ldap; namespace ZB.MOM.WW.OtOpcUa.Security.Tests; @@ -59,4 +61,22 @@ public sealed class RoleMapperTests roles.ShouldBe(new[] { "FleetAdmin" }); } + + [Fact] + public void Merge_unions_baseline_and_systemwide_db_roles() + { + var rows = new[] + { + new LdapGroupRoleMapping { LdapGroup = "g1", Role = AdminRole.FleetAdmin, IsSystemWide = true }, + new LdapGroupRoleMapping { LdapGroup = "g2", Role = AdminRole.ConfigEditor, IsSystemWide = false, ClusterId = "SITE-A" }, + }; + var result = RoleMapper.Merge(["ConfigViewer"], rows); + result.ShouldContain("ConfigViewer"); + result.ShouldContain("FleetAdmin"); + result.ShouldNotContain("ConfigEditor"); // cluster-scoped row ignored (global-only) + } + + [Fact] + public void Merge_with_no_db_rows_returns_baseline() + => RoleMapper.Merge(["FleetAdmin"], []).ShouldBe(["FleetAdmin"]); }