fix(mesh-phase4): driver-only nodes map LDAP groups from appsettings (no ConfigDb grants)
Claude-Session: https://claude.ai/code/session_01GASWkNEi68FSCtvr6rLoEW
This commit is contained in:
@@ -13,6 +13,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
|
|||||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||||
using ZB.MOM.WW.OtOpcUa.Cluster;
|
using ZB.MOM.WW.OtOpcUa.Cluster;
|
||||||
using ZB.MOM.WW.OtOpcUa.Configuration;
|
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;
|
||||||
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
using ZB.MOM.WW.OtOpcUa.ControlPlane.Redundancy;
|
||||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||||
@@ -319,11 +320,18 @@ if (hasDriver)
|
|||||||
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
|
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
|
||||||
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
|
// shared ZB.MOM.WW.Auth.Ldap service). The data-plane authenticator resolves IGroupRoleMapper
|
||||||
// <string> per call to turn the directory's groups into roles, so register it here for driver-
|
// <string> 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
|
// only nodes (AddOtOpcUaAuth registers it on admin nodes). OtOpcUaGroupRoleMapper also depends
|
||||||
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above.
|
// 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<ILdapGroupRoleMapping
|
||||||
|
// Service, LdapGroupRoleMappingService>, so this TryAdd is a no-op there and the real EF service
|
||||||
|
// wins.
|
||||||
// Note: AddValidatedOptions<LdapOptions, LdapOptionsValidator> is now registered unconditionally
|
// Note: AddValidatedOptions<LdapOptions, LdapOptionsValidator> is now registered unconditionally
|
||||||
// above both role blocks so admin-only nodes also get fail-fast LDAP startup validation.
|
// above both role blocks so admin-only nodes also get fail-fast LDAP startup validation.
|
||||||
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||||
|
builder.Services.TryAddScoped<ILdapGroupRoleMappingService, NullLdapGroupRoleMappingService>();
|
||||||
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Driver-only-node stand-in for the EF-backed <see cref="ILdapGroupRoleMappingService"/>.
|
||||||
|
/// A driver-only node (Akka role <c>driver</c>, not <c>admin</c>) has no ConfigDb connection
|
||||||
|
/// (Phase 4 gated <c>AddOtOpcUaConfigDb</c> on <c>hasAdmin</c>), so it can never read the
|
||||||
|
/// control-plane <c>LdapGroupRoleMapping</c> DB grants that <see cref="OtOpcUaGroupRoleMapper"/>
|
||||||
|
/// normally unions into its result. This implementation always reports "no DB grants" so the
|
||||||
|
/// data-path mapper falls back to exactly the appsettings <c>Security:Ldap:GroupToRole</c>
|
||||||
|
/// baseline — the same safe fallback the mapper already takes when the real service simply
|
||||||
|
/// has no matching rows. <see cref="LdapGroupRoleMapping"/> 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.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The write surface (<see cref="CreateAsync"/>/<see cref="DeleteAsync"/>) 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.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class NullLdapGroupRoleMappingService : ILdapGroupRoleMappingService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> GetByGroupsAsync(
|
||||||
|
IEnumerable<string> ldapGroups, CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(Array.Empty<LdapGroupRoleMapping>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<IReadOnlyList<LdapGroupRoleMapping>> ListAllAsync(CancellationToken cancellationToken)
|
||||||
|
=> Task.FromResult<IReadOnlyList<LdapGroupRoleMapping>>(Array.Empty<LdapGroupRoleMapping>());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task<LdapGroupRoleMapping> CreateAsync(LdapGroupRoleMapping row, CancellationToken cancellationToken)
|
||||||
|
=> throw new NotSupportedException(
|
||||||
|
"LDAP group-role grants are control-plane only; a driver-only node has no ConfigDb.");
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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.");
|
||||||
|
}
|
||||||
@@ -72,6 +72,27 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
|||||||
result.Roles.ShouldBeEmpty();
|
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<string, string>(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]
|
[Fact]
|
||||||
public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs()
|
public async Task Reproduces_RoleMapper_Map_plus_Merge_for_representative_inputs()
|
||||||
{
|
{
|
||||||
@@ -116,3 +137,49 @@ public sealed class OtOpcUaGroupRoleMapperTests
|
|||||||
=> throw new NotSupportedException();
|
=> throw new NotSupportedException();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves <see cref="NullLdapGroupRoleMappingService"/> — the driver-only-node stand-in for the
|
||||||
|
/// EF-backed <c>ILdapGroupRoleMappingService</c> — 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.
|
||||||
|
/// </summary>
|
||||||
|
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<NotSupportedException>(() => sut.CreateAsync(row, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteAsync_throws_NotSupportedException()
|
||||||
|
{
|
||||||
|
var sut = new NullLdapGroupRoleMappingService();
|
||||||
|
|
||||||
|
await Should.ThrowAsync<NotSupportedException>(() => sut.DeleteAsync(Guid.NewGuid(), CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user