From 833e45db9cda667200c2c69bad9a8b4ad6044399 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 23 Jul 2026 11:44:44 -0400 Subject: [PATCH] fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants) Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW --- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 12 +++- .../Ldap/NullLdapGroupRoleMappingService.cs | 44 ++++++++++++ .../OtOpcUaGroupRoleMapperTests.cs | 67 +++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/NullLdapGroupRoleMappingService.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 0ed23bda..3204646c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -13,6 +13,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; using ZB.MOM.WW.OtOpcUa.Cluster; using ZB.MOM.WW.OtOpcUa.Configuration; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; using ZB.MOM.WW.OtOpcUa.ControlPlane; using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy; using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; @@ -319,11 +320,18 @@ if (hasDriver) // OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the // shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper // per call to turn the directory's groups into roles, so register it here for driver- - // only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it - // depends on is already registered unconditionally by AddOtOpcUaConfigDb above. + // only nodes (AddOtOpcUaAuth registers it on admin nodes). OtOpcUaGroupRoleMapper also depends + // on ILdapGroupRoleMappingService (the control-plane DB-grants CRUD surface) — Phase 4 gated + // AddOtOpcUaConfigDb on hasAdmin, so a driver-only node has no ConfigDb to read it from. Bind + // NullLdapGroupRoleMappingService (always "no DB grants") here via TryAdd so the mapper falls + // back to the appsettings Security:Ldap:GroupToRole baseline; on a fused admin+driver node the + // hasAdmin block above already ran AddOtOpcUaConfigDb's non-Try AddScoped, so this TryAdd is a no-op there and the real EF service + // wins. // Note: AddValidatedOptions is now registered unconditionally // above both role blocks so admin-only nodes also get fail-fast LDAP startup validation. builder.Services.TryAddSingleton(); + builder.Services.TryAddScoped(); builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>(); builder.Services.AddSingleton(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/NullLdapGroupRoleMappingService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/NullLdapGroupRoleMappingService.cs new file mode 100644 index 00000000..15c91bfb --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/NullLdapGroupRoleMappingService.cs @@ -0,0 +1,44 @@ +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Services; + +namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; + +/// +/// Driver-only-node stand-in for the EF-backed . +/// A driver-only node (Akka role driver, not admin) has no ConfigDb connection +/// (Phase 4 gated AddOtOpcUaConfigDb on hasAdmin), so it can never read the +/// control-plane LdapGroupRoleMapping DB grants that +/// normally unions into its result. This implementation always reports "no DB grants" so the +/// data-path mapper falls back to exactly the appsettings Security:Ldap:GroupToRole +/// baseline — the same safe fallback the mapper already takes when the real service simply +/// has no matching rows. rows are not composed into the +/// deployment artifact either, so a driver node never had DB grants via config; reporting +/// empty here is honest, not lossy. +/// +/// +/// The write surface (/) is control-plane-only +/// Admin UI functionality that a driver-only node — which has no Admin UI — never calls. It +/// throws rather than silently no-opping so an accidental call surfaces immediately instead of +/// pretending to persist a grant that a driver-only node cannot store. +/// +public sealed class NullLdapGroupRoleMappingService : ILdapGroupRoleMappingService +{ + /// + public Task> GetByGroupsAsync( + IEnumerable ldapGroups, CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + /// + public Task> ListAllAsync(CancellationToken cancellationToken) + => Task.FromResult>(Array.Empty()); + + /// + public Task CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken) + => throw new NotSupportedException( + "LDAP group-role grants are control-plane only; a driver-only node has no ConfigDb."); + + /// + public Task DeleteAsync(Guid id, CancellationToken cancellationToken) + => throw new NotSupportedException( + "LDAP group-role grants are control-plane only; a driver-only node has no ConfigDb."); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs index bd6559b7..f444b8d6 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaGroupRoleMapperTests.cs @@ -72,6 +72,27 @@ public sealed class OtOpcUaGroupRoleMapperTests result.Roles.ShouldBeEmpty(); } + [Fact] + public async Task Driver_only_node_falls_back_to_appsettings_baseline_via_null_mapping_service() + { + // Simulates a driver-only node: no ConfigDb, so ILdapGroupRoleMappingService is bound to + // NullLdapGroupRoleMappingService instead of the EF-backed one. MapAsync must still resolve + // roles from Security:Ldap:GroupToRole alone, and must not throw for lack of a DB. + var options = Options.Create(new LdapOptions + { + GroupToRole = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["operators"] = "WriteOperate", + }, + }); + var mapper = new OtOpcUaGroupRoleMapper(options, new NullLdapGroupRoleMappingService()); + + var result = await mapper.MapAsync(["operators", "unmapped-group"], CancellationToken.None); + + result.Roles.ShouldBe(["WriteOperate"]); + result.Scope.ShouldBeNull(); + } + [Fact] public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs() { @@ -116,3 +137,49 @@ public sealed class OtOpcUaGroupRoleMapperTests => throw new NotSupportedException(); } } + +/// +/// Proves — the driver-only-node stand-in for the +/// EF-backed ILdapGroupRoleMappingService — always reports "no DB grants" rather than +/// throwing on the hot sign-in path, while its control-plane write surface (never called +/// without an AdminUI) fails loudly instead of silently no-opping. +/// +public sealed class NullLdapGroupRoleMappingServiceTests +{ + [Fact] + public async Task GetByGroupsAsync_returns_empty() + { + var sut = new NullLdapGroupRoleMappingService(); + + var result = await sut.GetByGroupsAsync(["any-group"], CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task ListAllAsync_returns_empty() + { + var sut = new NullLdapGroupRoleMappingService(); + + var result = await sut.ListAllAsync(CancellationToken.None); + + result.ShouldBeEmpty(); + } + + [Fact] + public async Task CreateAsync_throws_NotSupportedException() + { + var sut = new NullLdapGroupRoleMappingService(); + var row = new LdapGroupRoleMapping { LdapGroup = "g", Role = AdminRole.Administrator, IsSystemWide = true }; + + await Should.ThrowAsync(() => sut.CreateAsync(row, CancellationToken.None)); + } + + [Fact] + public async Task DeleteAsync_throws_NotSupportedException() + { + var sut = new NullLdapGroupRoleMappingService(); + + await Should.ThrowAsync(() => sut.DeleteAsync(Guid.NewGuid(), CancellationToken.None)); + } +}