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();
+ }
+}