feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4)
This commit is contained in:
@@ -20,9 +20,9 @@
|
||||
<div class="panel-head">LDAP binding</div>
|
||||
<div class="kv"><span class="k">Enabled</span><span class="v">@(_options.Enabled ? "yes" : "no")</span></div>
|
||||
<div class="kv"><span class="k">Server</span><span class="v mono">@_options.Server:@_options.Port</span></div>
|
||||
<div class="kv"><span class="k">UseTls</span><span class="v">@_options.UseTls</span></div>
|
||||
<div class="kv"><span class="k">Transport</span><span class="v">@_options.Transport</span></div>
|
||||
<div class="kv"><span class="k">SearchBase</span><span class="v mono small">@_options.SearchBase</span></div>
|
||||
@if (!_options.UseTls && _options.AllowInsecureLdap)
|
||||
@if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure)
|
||||
{
|
||||
<div class="kv"><span class="k">Warning</span><span class="v"><span class="chip chip-alert">Plaintext credentials over LDAP — dev mode only</span></span></div>
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
||||
/// TCP port; when disabled — or when <c>DevStubMode</c> bypasses the real bind — all checks are
|
||||
/// skipped. <c>ServiceAccountDn</c>/<c>Password</c> are
|
||||
/// intentionally not required — an empty pair selects the direct-bind path (see
|
||||
/// <see cref="LdapOptions.ServiceAccountDn"/>). Failure messages use <c>"Ldap:"</c> as a
|
||||
/// <see cref="LdapOptions.ServiceAccountDn"/>). The plaintext-transport-without-AllowInsecure
|
||||
/// guard is enforced at the auth boundary (<see cref="OtOpcUaLdapAuthService"/>) rather than here,
|
||||
/// to preserve the bespoke service's behaviour of booting and failing closed at login (not at
|
||||
/// startup) when a config selects insecure transport. Failure messages use <c>"Ldap:"</c> as a
|
||||
/// human-readable field prefix — not the literal bound section path, which is
|
||||
/// <c>Security:Ldap</c> (see <see cref="LdapOptions.SectionName"/>).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
@@ -8,15 +10,23 @@ namespace ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
/// Production <see cref="IOpcUaUserAuthenticator"/> adapter that bridges OPC UA UserName
|
||||
/// tokens to the same <see cref="ILdapAuthService"/> the Admin UI cookie/JWT flows use, so a
|
||||
/// single LDAP source-of-truth governs both control-plane (Admin) and data-plane (OPC UA)
|
||||
/// session identities. Roles flow through unchanged — the data-plane ACL evaluator reads
|
||||
/// them off <c>OperationContext.UserIdentity</c> downstream.
|
||||
/// session identities. Roles are resolved through the shared
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/> seam from the LDAP groups returned by the directory —
|
||||
/// the same seam the login endpoint uses — and the resolved set is attached to the OPC UA
|
||||
/// session identity for the downstream data-plane ACL evaluator.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This authenticator is registered as a singleton, but <see cref="IGroupRoleMapper{TRole}"/>
|
||||
/// (and its DbContext-backed mapping service) is scoped. A per-call DI scope is opened to
|
||||
/// resolve the mapper so the singleton never captures a scoped dependency.
|
||||
/// </remarks>
|
||||
public sealed class LdapOpcUaUserAuthenticator(
|
||||
ILdapAuthService ldap,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<LdapOpcUaUserAuthenticator> logger)
|
||||
: IOpcUaUserAuthenticator
|
||||
{
|
||||
/// <summary>Authenticates an OPC UA UserName token via LDAP.</summary>
|
||||
/// <summary>Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password to authenticate.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
@@ -29,7 +39,9 @@ public sealed class LdapOpcUaUserAuthenticator(
|
||||
{
|
||||
return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials");
|
||||
}
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, result.Roles);
|
||||
|
||||
var roles = await ResolveRolesAsync(result.Groups, result.Roles, username, ct).ConfigureAwait(false);
|
||||
return OpcUaUserAuthResult.Allow(result.DisplayName ?? username, roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
@@ -37,4 +49,36 @@ public sealed class LdapOpcUaUserAuthenticator(
|
||||
return OpcUaUserAuthResult.Deny("Authentication backend error");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the user's roles from their LDAP groups via the scoped
|
||||
/// <see cref="IGroupRoleMapper{TRole}"/>, unioned with any pre-resolved roles (the DevStub
|
||||
/// FleetAdmin grant). A mapper fault (e.g. a DB outage) must not deny an otherwise-authenticated
|
||||
/// session: it falls back to the pre-resolved roles, matching the login endpoint's behaviour.
|
||||
/// </summary>
|
||||
/// <param name="groups">The LDAP groups returned by the directory.</param>
|
||||
/// <param name="preResolved">Pre-resolved roles (empty on the real path; FleetAdmin under DevStub).</param>
|
||||
/// <param name="username">The login name, for diagnostics.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
private async Task<IReadOnlyList<string>> ResolveRolesAsync(
|
||||
IReadOnlyList<string> groups, IReadOnlyList<string> preResolved, string username, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var mapper = scope.ServiceProvider.GetRequiredService<IGroupRoleMapper<string>>();
|
||||
var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false);
|
||||
|
||||
var roles = new HashSet<string>(preResolved, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var role in mapping.Roles)
|
||||
roles.Add(role);
|
||||
return [.. roles];
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogWarning(ex,
|
||||
"Role-map lookup failed for OPC UA user {User}; using pre-resolved baseline roles", username);
|
||||
return preResolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Runtime;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Endpoints;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
@@ -101,9 +102,15 @@ if (hasDriver)
|
||||
builder.Services.AddSingleton<IScriptedAlarmEvaluator>(sp => sp.GetRequiredService<RoslynScriptedAlarmEvaluator>());
|
||||
|
||||
builder.Services.AddValidatedOptions<LdapOptions, LdapOptionsValidator>(builder.Configuration, LdapOptions.SectionName);
|
||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers this) ends up
|
||||
// with exactly one descriptor; on a driver-only node this is the sole registration.
|
||||
builder.Services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
// TryAdd so a fused admin+driver node (where AddOtOpcUaAuth also registers these) ends up
|
||||
// with exactly one descriptor; on a driver-only node these are the sole registrations.
|
||||
// OtOpcUaLdapAuthService is the app ILdapAuthService (Enabled switch + DevStubMode over the
|
||||
// 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-
|
||||
// only nodes (AddOtOpcUaAuth registers it on admin nodes); ILdapGroupRoleMappingService it
|
||||
// depends on is already registered unconditionally by AddOtOpcUaConfigDb above.
|
||||
builder.Services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||
builder.Services.TryAddScoped<IGroupRoleMapper<string>, OtOpcUaGroupRoleMapper>();
|
||||
builder.Services.AddSingleton<IOpcUaUserAuthenticator, LdapOpcUaUserAuthenticator>();
|
||||
|
||||
// Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart)
|
||||
|
||||
@@ -8,7 +8,7 @@ using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Configuration.Services;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Jwt;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
@@ -43,7 +43,7 @@ public static class AuthEndpoints
|
||||
private static async Task<IResult> LoginAsync(
|
||||
HttpContext http,
|
||||
ILdapAuthService ldap,
|
||||
ILdapGroupRoleMappingService roleMappings,
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var isForm = http.Request.HasFormContentType;
|
||||
@@ -87,18 +87,25 @@ public static class AuthEndpoints
|
||||
return Results.Redirect("/login" + qs);
|
||||
}
|
||||
|
||||
// Role resolution now lives behind the shared IGroupRoleMapper<string> seam
|
||||
// (OtOpcUaGroupRoleMapper): it applies the appsettings GroupToRole baseline AND merges
|
||||
// system-wide DB grants from the user's LDAP groups. result.Roles is empty on the real
|
||||
// LDAP path (the library returns groups, not roles); it is only pre-populated on the
|
||||
// DevStub success path (FleetAdmin) — union that pre-resolved set in so the dev grant
|
||||
// survives the move to the mapper.
|
||||
IReadOnlyList<string> roles = result.Roles;
|
||||
try
|
||||
{
|
||||
var dbRows = await roleMappings.GetByGroupsAsync(result.Groups, ct);
|
||||
roles = RoleMapper.Merge(result.Roles, dbRows);
|
||||
var mapping = await roleMapper.MapAsync(result.Groups, ct);
|
||||
roles = Union(result.Roles, mapping.Roles);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// A DB hiccup must never block sign-in — fall back to the appsettings baseline roles.
|
||||
// A DB hiccup (or any mapper fault) must never block sign-in — fall back to the
|
||||
// pre-resolved baseline roles (empty on the real path, FleetAdmin under DevStub).
|
||||
http.RequestServices.GetService<ILoggerFactory>()?
|
||||
.CreateLogger("ZB.MOM.WW.OtOpcUa.Security.AuthEndpoints")
|
||||
.LogWarning(ex, "DB role-map lookup failed for {User}; using appsettings baseline roles", username);
|
||||
.LogWarning(ex, "Role-map lookup failed for {User}; using pre-resolved baseline roles", username);
|
||||
}
|
||||
|
||||
var claims = new List<Claim>
|
||||
@@ -119,6 +126,21 @@ public static class AuthEndpoints
|
||||
return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the
|
||||
/// legacy <c>RoleMapper.Merge</c> applied. Used to fold any pre-resolved roles (the DevStub
|
||||
/// FleetAdmin grant) into the mapper-resolved set.
|
||||
/// </summary>
|
||||
/// <param name="first">The first role set (pre-resolved baseline).</param>
|
||||
/// <param name="second">The second role set (mapper output).</param>
|
||||
private static IReadOnlyList<string> Union(IReadOnlyList<string> first, IReadOnlyList<string> second)
|
||||
{
|
||||
var roles = new HashSet<string>(first, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var role in second)
|
||||
roles.Add(role);
|
||||
return [.. roles];
|
||||
}
|
||||
|
||||
private static IResult Ping(HttpContext http) =>
|
||||
http.User.Identity?.IsAuthenticated == true ? Results.Ok() : Results.Unauthorized();
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// LDAP bind-and-search authentication mirrored from ScadaLink's <c>LdapAuthService</c>
|
||||
/// (CLAUDE.md memory: <c>scadalink_reference.md</c>) — same bind semantics, TLS guard, and
|
||||
/// service-account search-then-bind path. Adapted for the Admin app's role-mapping shape
|
||||
/// (LDAP group names → Admin roles via <see cref="LdapOptions.GroupToRole"/>).
|
||||
/// </summary>
|
||||
public sealed class LdapAuthService(IOptions<LdapOptions> options, ILogger<LdapAuthService> logger)
|
||||
: ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options = options.Value;
|
||||
|
||||
/// <summary>Authenticates a user via LDAP bind and retrieves their group memberships and roles.</summary>
|
||||
/// <param name="username">The username to authenticate.</param>
|
||||
/// <param name="password">The password to validate against the LDAP directory.</param>
|
||||
/// <param name="ct">A cancellation token to observe while waiting for the operation to complete.</param>
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||
if (!_options.Enabled)
|
||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, null, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
logger.LogWarning("LdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
}
|
||||
|
||||
if (!_options.UseTls && !_options.AllowInsecureLdap)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable UseTls or set AllowInsecureLdap for dev/test.");
|
||||
|
||||
try
|
||||
{
|
||||
using var conn = new LdapConnection();
|
||||
if (_options.UseTls) conn.SecureSocketLayer = true;
|
||||
|
||||
await Task.Run(() => conn.Connect(_options.Server, _options.Port), ct);
|
||||
|
||||
var bindDn = await ResolveUserDnAsync(conn, username, ct);
|
||||
await Task.Run(() => conn.Bind(bindDn, password), ct);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
await Task.Run(() => conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var displayName = username;
|
||||
var groups = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
var filter = $"(cn={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter,
|
||||
attrs: null, // request ALL attributes so we can inspect memberOf + dn-derived group
|
||||
typesOnly: false), ct);
|
||||
|
||||
while (results.HasMore())
|
||||
{
|
||||
try
|
||||
{
|
||||
var entry = results.Next();
|
||||
var name = entry.GetAttribute(_options.DisplayNameAttribute);
|
||||
if (name is not null) displayName = name.StringValue;
|
||||
|
||||
var groupAttr = entry.GetAttribute(_options.GroupAttribute);
|
||||
if (groupAttr is not null)
|
||||
{
|
||||
foreach (var groupDn in groupAttr.StringValueArray)
|
||||
groups.Add(ExtractFirstRdnValue(groupDn));
|
||||
}
|
||||
|
||||
// Fallback: GLAuth places users under ou=PrimaryGroup,baseDN. When the
|
||||
// directory doesn't populate memberOf (or populates it differently), the
|
||||
// user's primary group name is recoverable from the second RDN of the DN.
|
||||
if (groups.Count == 0 && !string.IsNullOrEmpty(entry.Dn))
|
||||
{
|
||||
var primary = ExtractOuSegment(entry.Dn);
|
||||
if (primary is not null) groups.Add(primary);
|
||||
}
|
||||
}
|
||||
catch (LdapException) { break; } // no-more-entries signalled by exception
|
||||
}
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP attribute lookup failed for {User}", username);
|
||||
}
|
||||
|
||||
conn.Disconnect();
|
||||
|
||||
var roles = RoleMapper.Map(groups, _options.GroupToRole);
|
||||
return new(true, displayName, username, groups, roles, null);
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogWarning(ex, "LDAP bind failed for {User}", username);
|
||||
return new(false, null, username, [], [], "Invalid username or password");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP error for {User}", username);
|
||||
return new(false, null, username, [], [], "Unexpected authentication error");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveUserDnAsync(LdapConnection conn, string username, CancellationToken ct)
|
||||
{
|
||||
if (username.Contains('=')) return username; // already a DN
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.ServiceAccountDn))
|
||||
{
|
||||
await Task.Run(() =>
|
||||
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword), ct);
|
||||
|
||||
var filter = $"({_options.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
var results = await Task.Run(() =>
|
||||
conn.Search(_options.SearchBase, LdapConnection.ScopeSub, filter, ["dn"], false), ct);
|
||||
|
||||
if (results.HasMore())
|
||||
return results.Next().Dn;
|
||||
|
||||
throw new LdapException("User not found", LdapException.NoSuchObject,
|
||||
$"No entry for {filter}");
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(_options.SearchBase)
|
||||
? $"cn={username}"
|
||||
: $"cn={username},{_options.SearchBase}";
|
||||
}
|
||||
|
||||
/// <summary>Escapes special characters in an LDAP filter string according to RFC 4515.</summary>
|
||||
/// <param name="input">The unescaped string to escape.</param>
|
||||
/// <returns>The escaped LDAP filter string.</returns>
|
||||
internal static string EscapeLdapFilter(string input) =>
|
||||
input.Replace("\\", "\\5c")
|
||||
.Replace("*", "\\2a")
|
||||
.Replace("(", "\\28")
|
||||
.Replace(")", "\\29")
|
||||
.Replace("\0", "\\00");
|
||||
|
||||
/// <summary>
|
||||
/// Pulls the first <c>ou=Value</c> segment from a DN. GLAuth encodes a user's primary
|
||||
/// group as an <c>ou=</c> RDN immediately above the user's <c>cn=</c>, so this recovers
|
||||
/// the group name when <see cref="LdapOptions.GroupAttribute"/> is absent from the entry.
|
||||
/// </summary>
|
||||
/// <param name="dn">The distinguished name to extract the OU from.</param>
|
||||
/// <returns>The extracted OU value, or null if no OU segment is found.</returns>
|
||||
internal static string? ExtractOuSegment(string dn)
|
||||
{
|
||||
var segments = dn.Split(',');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
var trimmed = segment.Trim();
|
||||
if (trimmed.StartsWith("ou=", StringComparison.OrdinalIgnoreCase))
|
||||
return trimmed[3..];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the value portion of the first RDN (relative distinguished name) from a DN.</summary>
|
||||
/// <param name="dn">The distinguished name to extract from.</param>
|
||||
/// <returns>The value of the first RDN.</returns>
|
||||
internal static string ExtractFirstRdnValue(string dn)
|
||||
{
|
||||
var equalsIdx = dn.IndexOf('=');
|
||||
if (equalsIdx < 0) return dn;
|
||||
|
||||
var valueStart = equalsIdx + 1;
|
||||
var commaIdx = dn.IndexOf(',', valueStart);
|
||||
return commaIdx > valueStart ? dn[valueStart..commaIdx] : dn[valueStart..];
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using LibLdapOptions = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
@@ -5,6 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
/// <c>Security:Ldap</c> section. Defaults point at the local GLAuth dev instance (see
|
||||
/// <c>C:\publish\glauth\auth.md</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Carries both the wire fields the shared <c>ZB.MOM.WW.Auth.Ldap</c> directory client needs
|
||||
/// (<see cref="Server"/>/<see cref="Port"/>/<see cref="Transport"/>/…) AND the app-only concerns
|
||||
/// the shared library has no notion of (<see cref="Enabled"/> master switch,
|
||||
/// <see cref="DevStubMode"/> dev bypass, <see cref="GroupToRole"/> appsettings role baseline).
|
||||
/// The app wrapper (<c>OtOpcUaLdapAuthService</c>) projects this onto the library's
|
||||
/// <see cref="LibLdapOptions"/> at construction; see <see cref="ToLibraryOptions"/>.
|
||||
/// </remarks>
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public const string SectionName = "Security:Ldap";
|
||||
@@ -18,14 +29,21 @@ public sealed class LdapOptions
|
||||
/// <summary>Gets or sets the LDAP server port.</summary>
|
||||
public int Port { get; set; } = 3893;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether to use TLS for LDAP connection.</summary>
|
||||
public bool UseTls { get; set; }
|
||||
/// <summary>
|
||||
/// Transport security for the LDAP connection — <see cref="LdapTransport.Ldaps"/> (implicit
|
||||
/// TLS), <see cref="LdapTransport.StartTls"/> (upgrade), or <see cref="LdapTransport.None"/>
|
||||
/// (plaintext, dev/test only — requires <see cref="AllowInsecure"/>). Replaces the former
|
||||
/// <c>UseTls</c> bool (Task 1.4): <c>true</c>→<see cref="LdapTransport.Ldaps"/>,
|
||||
/// <c>false</c>→<see cref="LdapTransport.None"/>.
|
||||
/// </summary>
|
||||
public LdapTransport Transport { get; set; } = LdapTransport.None;
|
||||
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production.</summary>
|
||||
public bool AllowInsecureLdap { get; set; }
|
||||
/// <summary>Dev-only escape hatch — must be <c>false</c> in production. Maps to the shared
|
||||
/// library's <see cref="LibLdapOptions.AllowInsecure"/> (renamed from <c>AllowInsecureLdap</c>).</summary>
|
||||
public bool AllowInsecure { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="LdapAuthService"/> bypasses the real LDAP
|
||||
/// Dev-only stub: when <c>true</c>, <see cref="OtOpcUaLdapAuthService"/> bypasses the real LDAP
|
||||
/// bind and accepts any non-empty username/password, returning a single FleetAdmin role
|
||||
/// so the operator can navigate the full Admin UI. MUST be <c>false</c> in production.
|
||||
/// </summary>
|
||||
@@ -62,4 +80,26 @@ public sealed class LdapOptions
|
||||
/// <code>"ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin"</code>
|
||||
/// </summary>
|
||||
public Dictionary<string, string> GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Projects the wire fields onto the shared <c>ZB.MOM.WW.Auth.Ldap</c>
|
||||
/// <see cref="LibLdapOptions"/> the directory client consumes. App-only concerns
|
||||
/// (<see cref="DevStubMode"/>, <see cref="GroupToRole"/>) have no library counterpart and are
|
||||
/// handled by the app wrapper around the library service; <see cref="Enabled"/> is carried
|
||||
/// through so the library's own feature gate stays consistent with the app master switch.
|
||||
/// </summary>
|
||||
public LibLdapOptions ToLibraryOptions() => new()
|
||||
{
|
||||
Enabled = Enabled,
|
||||
Server = Server,
|
||||
Port = Port,
|
||||
Transport = Transport,
|
||||
AllowInsecure = AllowInsecure,
|
||||
SearchBase = SearchBase,
|
||||
ServiceAccountDn = ServiceAccountDn,
|
||||
ServiceAccountPassword = ServiceAccountPassword,
|
||||
UserNameAttribute = UserNameAttribute,
|
||||
DisplayNameAttribute = DisplayNameAttribute,
|
||||
GroupAttribute = GroupAttribute,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||
using LibLdapAuthService = ZB.MOM.WW.Auth.Ldap.LdapAuthService;
|
||||
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
/// <summary>
|
||||
/// OtOpcUa's application <see cref="ILdapAuthService"/> — a thin wrapper around the shared
|
||||
/// <c>ZB.MOM.WW.Auth.Ldap</c> directory client that adds the two app-only concerns the shared
|
||||
/// library deliberately does not model:
|
||||
/// <list type="number">
|
||||
/// <item>the <see cref="LdapOptions.Enabled"/> master switch (disabled ⇒ deny, no bind); and</item>
|
||||
/// <item><see cref="LdapOptions.DevStubMode"/> — the dev bypass that grants a FleetAdmin
|
||||
/// session WITHOUT touching the network, so an operator can navigate the full Admin UI
|
||||
/// against a machine with no directory.</item>
|
||||
/// </list>
|
||||
/// On the real path it delegates to the library <see cref="LibLdapAuthService"/> and adapts the
|
||||
/// library result (which returns <em>groups</em>, never roles) back onto the app's
|
||||
/// <see cref="LdapAuthResult"/> shape. Role resolution itself now lives downstream in
|
||||
/// <see cref="OtOpcUaGroupRoleMapper"/> (the <c>IGroupRoleMapper<string></c> seam), which
|
||||
/// both the login endpoint and the OPC UA data-plane authenticator call with the returned
|
||||
/// <see cref="LdapAuthResult.Groups"/>. The only path that pre-populates
|
||||
/// <see cref="LdapAuthResult.Roles"/> is the DevStub success; consumers union that pre-resolved
|
||||
/// set with the mapper output so the dev FleetAdmin grant survives the move to the mapper.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The
|
||||
/// DevStub result mirrors the legacy bespoke service exactly (group <c>"dev"</c>, role
|
||||
/// <c>"FleetAdmin"</c>) so behaviour is preserved bit-for-bit on dev nodes.
|
||||
/// </remarks>
|
||||
public sealed class OtOpcUaLdapAuthService : ILdapAuthService
|
||||
{
|
||||
private readonly LdapOptions _options;
|
||||
private readonly LibILdapAuthService _inner;
|
||||
private readonly ILogger<OtOpcUaLdapAuthService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor: builds the shared-library directory client from the wire fields
|
||||
/// of the bound app <see cref="LdapOptions"/>.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options (wire fields + app-only concerns).</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public OtOpcUaLdapAuthService(IOptions<LdapOptions> options, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
: this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seam constructor: accepts an injected library <see cref="LibILdapAuthService"/> so the
|
||||
/// Enabled/DevStub/delegation logic can be unit-tested without a live directory.
|
||||
/// </summary>
|
||||
/// <param name="options">The app LDAP options.</param>
|
||||
/// <param name="inner">The shared-library directory client to delegate the real path to.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger<OtOpcUaLdapAuthService> logger)
|
||||
{
|
||||
_options = options;
|
||||
_inner = inner;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
{
|
||||
// Enabled is the master switch and wins over DevStubMode — when LDAP auth is turned off,
|
||||
// refuse to authenticate at all (no bind, no dev-stub bypass).
|
||||
if (!_options.Enabled)
|
||||
return new(false, null, null, [], [], "LDAP authentication is disabled.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username))
|
||||
return new(false, null, null, [], [], "Username is required");
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return new(false, null, username, [], [], "Password is required");
|
||||
|
||||
if (_options.DevStubMode)
|
||||
{
|
||||
// Dev bypass: accept any non-empty credentials and grant FleetAdmin WITHOUT a real bind.
|
||||
// Pre-populated Roles are unioned with the mapper output by both consumers, so the grant
|
||||
// survives the move to IGroupRoleMapper. Mirrors the legacy bespoke service exactly.
|
||||
_logger.LogWarning(
|
||||
"OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username);
|
||||
return new(true, username, username, ["dev"], ["FleetAdmin"], null);
|
||||
}
|
||||
|
||||
// Fail closed on a plaintext transport unless explicitly opted in. The bespoke service
|
||||
// enforced this at login (not startup), so the host still boots with an insecure-by-default
|
||||
// config and only refuses the bind here — preserved verbatim after the UseTls→Transport
|
||||
// migration (Task 1.4). The shared library's directory client does not re-check this.
|
||||
if (_options.Transport == LdapTransport.None && !_options.AllowInsecure)
|
||||
return new(false, null, username, [], [],
|
||||
"Insecure LDAP is disabled. Enable a TLS transport or set AllowInsecure for dev/test.");
|
||||
|
||||
var libResult = await _inner.AuthenticateAsync(username, password, ct).ConfigureAwait(false);
|
||||
return Adapt(libResult, username);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the shared-library <see cref="LibLdapAuthResult"/> onto the app's
|
||||
/// <see cref="LdapAuthResult"/>. The library returns groups (never roles) on success, so
|
||||
/// <see cref="LdapAuthResult.Roles"/> is left empty on the delegated path — role resolution
|
||||
/// happens downstream in the mapper. Library <see cref="LdapAuthFailure"/> codes are folded
|
||||
/// into the user-facing error strings the app already surfaces, keeping fail-closed semantics.
|
||||
/// </summary>
|
||||
/// <param name="result">The library authentication result.</param>
|
||||
/// <param name="username">The login name, used to populate the app result's Username field.</param>
|
||||
private static LdapAuthResult Adapt(LibLdapAuthResult result, string username)
|
||||
{
|
||||
if (result.Succeeded)
|
||||
return new(true, result.DisplayName, result.Username, result.Groups, [], null);
|
||||
|
||||
return new(false, null, username, [], [], FailureToError(result.Failure));
|
||||
}
|
||||
|
||||
/// <summary>Folds a structured library failure code into the app's user-facing error text.</summary>
|
||||
/// <param name="failure">The library failure code (null defensively treated as a generic error).</param>
|
||||
private static string FailureToError(LdapAuthFailure? failure) => failure switch
|
||||
{
|
||||
// The directory found no single matching user, or the password did not verify — both
|
||||
// surface as the same opaque message so a probe cannot distinguish "unknown user" from
|
||||
// "wrong password".
|
||||
LdapAuthFailure.BadCredentials => "Invalid username or password",
|
||||
LdapAuthFailure.UserNotFound => "Invalid username or password",
|
||||
LdapAuthFailure.AmbiguousUser => "Invalid username or password",
|
||||
LdapAuthFailure.GroupLookupFailed => "Invalid username or password",
|
||||
// System-side faults (directory unreachable / service-account misconfiguration) — kept as a
|
||||
// generic backend message rather than leaking the cause to the caller.
|
||||
LdapAuthFailure.ServiceAccountBindFailed => "Unexpected authentication error",
|
||||
LdapAuthFailure.Disabled => "LDAP authentication is disabled.",
|
||||
_ => "Unexpected authentication error",
|
||||
};
|
||||
}
|
||||
@@ -36,11 +36,13 @@ public static class ServiceCollectionExtensions
|
||||
services.AddOptions<LdapOptions>().Bind(configuration.GetSection(LdapOptions.SectionName));
|
||||
|
||||
services.AddSingleton<JwtTokenService>();
|
||||
// Singleton — LdapAuthService is stateless (creates an LdapConnection per call) and
|
||||
// must be consumable by the Singleton LdapOpcUaUserAuthenticator on driver-role nodes.
|
||||
// TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
||||
// Singleton — OtOpcUaLdapAuthService is stateless (the shared-library directory client it
|
||||
// wraps opens/disposes an LdapConnection per call) and must be consumable by the Singleton
|
||||
// LdapOpcUaUserAuthenticator on driver-role nodes. This is the app's ILdapAuthService: it
|
||||
// adds the Enabled master switch + DevStubMode bypass on top of the shared ZB.MOM.WW.Auth.Ldap
|
||||
// service. TryAdd so a fused admin+driver node (which also registers it in Program.cs for the
|
||||
// driver path) ends up with exactly one descriptor regardless of registration order.
|
||||
services.TryAddSingleton<ILdapAuthService, LdapAuthService>();
|
||||
services.TryAddSingleton<ILdapAuthService, OtOpcUaLdapAuthService>();
|
||||
|
||||
// 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
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.IdentityModel.Tokens"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="Novell.Directory.Ldap.NETStandard"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Abstractions"/>
|
||||
<PackageReference Include="ZB.MOM.WW.Auth.Ldap"/>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+72
-11
@@ -1,24 +1,32 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.OtOpcUa.Host.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// F13c — verifies <see cref="LdapOpcUaUserAuthenticator"/> faithfully translates
|
||||
/// <see cref="ILdapAuthService"/> outcomes into <c>OpcUaUserAuthResult</c> and turns LDAP
|
||||
/// backend exceptions into a denial rather than letting them escape into the SDK.
|
||||
/// Verifies <see cref="LdapOpcUaUserAuthenticator"/> translates app <see cref="ILdapAuthService"/>
|
||||
/// outcomes into <c>OpcUaUserAuthResult</c>, resolves roles from the directory's <em>groups</em>
|
||||
/// through the shared <see cref="IGroupRoleMapper{TRole}"/> seam (Task 1.2), unions any pre-resolved
|
||||
/// roles (the DevStub FleetAdmin grant) in, and turns LDAP backend exceptions into a denial rather
|
||||
/// than letting them escape into the SDK.
|
||||
/// </summary>
|
||||
public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
{
|
||||
/// <summary>Verifies that successful LDAP authentication returns Allow result with user roles.</summary>
|
||||
/// <summary>On success the data-plane authenticator resolves roles via the mapper from the
|
||||
/// returned Groups — not from the auth result's Roles field — and grants identity.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_success_returns_Allow_with_roles()
|
||||
public async Task Authenticate_LDAP_success_resolves_roles_via_mapper_from_groups()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, new[] { "ConfigEditor" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
// Library-style result: groups present, Roles empty (the real path). The mapper maps the
|
||||
// group "configeditor" -> "ConfigEditor".
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "Alice", "alice", new[] { "configeditor" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
@@ -27,12 +35,45 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
result.Roles.ShouldBe(new[] { "ConfigEditor" });
|
||||
}
|
||||
|
||||
/// <summary>The DevStub pre-resolved roles (FleetAdmin) survive the move to the mapper: they are
|
||||
/// unioned with the mapper output so the dev grant still reaches the OPC UA session.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_devstub_preresolved_roles_are_unioned_with_mapper()
|
||||
{
|
||||
// DevStub-shaped result: group "dev", pre-resolved role "FleetAdmin". Mapper maps "dev" to
|
||||
// nothing, so the union is exactly {FleetAdmin}.
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||
var mapper = new FakeMapper(_ => Array.Empty<string>());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
}
|
||||
|
||||
/// <summary>A mapper fault (e.g. DB outage) must not deny an authenticated session — it falls
|
||||
/// back to the pre-resolved roles, matching the login endpoint's behaviour.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_mapper_fault_falls_back_to_preresolved_roles()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, "dev", "dev", new[] { "dev" }, new[] { "FleetAdmin" }, null));
|
||||
var mapper = new FakeMapper(_ => throw new InvalidOperationException("DB down"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
}
|
||||
|
||||
/// <summary>Verifies that LDAP authentication failure returns Deny result with error text.</summary>
|
||||
[Fact]
|
||||
public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty<string>(), Array.Empty<string>(), "Invalid username or password"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("mallory", "wrong", CancellationToken.None);
|
||||
|
||||
@@ -45,7 +86,8 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public async Task Authenticate_LDAP_exception_returns_backend_error_denial()
|
||||
{
|
||||
var ldap = new FakeLdap(_ => throw new InvalidOperationException("LDAP unreachable"));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("anyone", "x", CancellationToken.None);
|
||||
|
||||
@@ -58,8 +100,9 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
[Fact]
|
||||
public async Task Authenticate_falls_back_to_username_when_LDAP_omits_display_name()
|
||||
{
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", Array.Empty<string>(), new[] { "ReadOnly" }, null));
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty<string>(), null));
|
||||
var mapper = new FakeMapper(g => g.ToArray());
|
||||
var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger<LdapOpcUaUserAuthenticator>.Instance);
|
||||
|
||||
var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None);
|
||||
|
||||
@@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
result.DisplayName.ShouldBe("alice");
|
||||
}
|
||||
|
||||
/// <summary>Builds an IServiceScopeFactory whose scopes resolve the supplied mapper.</summary>
|
||||
private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper<string> mapper)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped(_ => mapper);
|
||||
return services.BuildServiceProvider().GetRequiredService<IServiceScopeFactory>();
|
||||
}
|
||||
|
||||
/// <summary>Test fake implementation of LDAP authentication service.</summary>
|
||||
private sealed class FakeLdap : ILdapAuthService
|
||||
{
|
||||
@@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests
|
||||
public Task<LdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct = default)
|
||||
=> Task.FromResult(_handler(username));
|
||||
}
|
||||
|
||||
/// <summary>Test fake group→role mapper driven by a delegate over the supplied groups.</summary>
|
||||
private sealed class FakeMapper(Func<IReadOnlyList<string>, IReadOnlyList<string>> map) : IGroupRoleMapper<string>
|
||||
{
|
||||
/// <summary>Maps groups to roles via the configured delegate; Scope is always null.</summary>
|
||||
/// <param name="groups">The LDAP groups to map.</param>
|
||||
/// <param name="ct">The cancellation token.</param>
|
||||
public Task<GroupRoleMapping<string>> MapAsync(IReadOnlyList<string> groups, CancellationToken ct)
|
||||
=> Task.FromResult(new GroupRoleMapping<string>(map(groups), Scope: null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,14 +178,16 @@ public sealed class TwoNodeClusterHarness : IAsyncDisposable
|
||||
|
||||
if (harness.Mode.UseRealLdap)
|
||||
{
|
||||
configOverrides["Authentication:Ldap:Enabled"] = "true";
|
||||
configOverrides["Authentication:Ldap:Server"] = "localhost";
|
||||
configOverrides["Authentication:Ldap:Port"] = "3894";
|
||||
configOverrides["Authentication:Ldap:UseTls"] = "false";
|
||||
configOverrides["Authentication:Ldap:AllowInsecureLdap"] = "true";
|
||||
configOverrides["Authentication:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
|
||||
configOverrides["Authentication:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
|
||||
configOverrides["Authentication:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
||||
// Bound section is Security:Ldap (see LdapOptions.SectionName); Transport replaces the
|
||||
// old UseTls bool and AllowInsecure replaces AllowInsecureLdap (Task 1.4).
|
||||
configOverrides["Security:Ldap:Enabled"] = "true";
|
||||
configOverrides["Security:Ldap:Server"] = "localhost";
|
||||
configOverrides["Security:Ldap:Port"] = "3894";
|
||||
configOverrides["Security:Ldap:Transport"] = "None";
|
||||
configOverrides["Security:Ldap:AllowInsecure"] = "true";
|
||||
configOverrides["Security:Ldap:SearchBase"] = "dc=lmxopcua,dc=local";
|
||||
configOverrides["Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=lmxopcua,dc=local";
|
||||
configOverrides["Security:Ldap:ServiceAccountPassword"] = "ldapadmin";
|
||||
}
|
||||
|
||||
builder.Configuration.AddInMemoryCollection(configOverrides);
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
public sealed class LdapHelperTests
|
||||
{
|
||||
/// <summary>Verifies that LDAP filter special characters are properly escaped.</summary>
|
||||
/// <param name="input">The input string.</param>
|
||||
/// <param name="expected">The expected escaped output.</param>
|
||||
[Theory]
|
||||
[InlineData("joe", "joe")]
|
||||
[InlineData("jo*e", "jo\\2ae")]
|
||||
[InlineData("jo(e", "jo\\28e")]
|
||||
[InlineData("jo)e", "jo\\29e")]
|
||||
[InlineData("jo\\e", "jo\\5ce")]
|
||||
public void EscapeLdapFilter_escapes_special_chars(string input, string expected)
|
||||
{
|
||||
LdapAuthService.EscapeLdapFilter(input).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first organizational unit segment is correctly extracted from a DN.</summary>
|
||||
/// <param name="dn">The distinguished name.</param>
|
||||
/// <param name="expected">The expected organizational unit value.</param>
|
||||
[Theory]
|
||||
[InlineData("cn=joe,ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
[InlineData("cn=alice,dc=lmxopcua,dc=local", null)]
|
||||
[InlineData("ou=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
public void ExtractOuSegment_returns_first_ou(string dn, string? expected)
|
||||
{
|
||||
LdapAuthService.ExtractOuSegment(dn).ShouldBe(expected);
|
||||
}
|
||||
|
||||
/// <summary>Verifies that the first RDN value is correctly extracted from various DN formats.</summary>
|
||||
/// <param name="dn">The distinguished name.</param>
|
||||
/// <param name="expected">The expected RDN value.</param>
|
||||
[Theory]
|
||||
[InlineData("cn=Admins,dc=lmxopcua,dc=local", "Admins")]
|
||||
[InlineData("cn=Admins", "Admins")]
|
||||
[InlineData("Admins", "Admins")]
|
||||
public void ExtractFirstRdnValue_handles_full_and_short_dns(string dn, string expected)
|
||||
{
|
||||
LdapAuthService.ExtractFirstRdnValue(dn).ShouldBe(expected);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Security.Ldap;
|
||||
using LdapTransport = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport;
|
||||
using LdapAuthFailure = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthFailure;
|
||||
using LibILdapAuthService = ZB.MOM.WW.Auth.Abstractions.Ldap.ILdapAuthService;
|
||||
using LibLdapAuthResult = ZB.MOM.WW.Auth.Abstractions.Ldap.LdapAuthResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Security.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Task 1.2 — proves <see cref="OtOpcUaLdapAuthService"/> (the app's ILdapAuthService wrapper over
|
||||
/// the shared <c>ZB.MOM.WW.Auth.Ldap</c> service) preserves the two app-only concerns the library
|
||||
/// does not model: the <c>Enabled</c> master switch and the <c>DevStubMode</c> bypass. Both must
|
||||
/// short-circuit WITHOUT delegating to the library. On the real path it adapts the library result
|
||||
/// (groups, never roles) onto the app result shape with roles left for the downstream mapper.
|
||||
/// </summary>
|
||||
public sealed class OtOpcUaLdapAuthServiceTests
|
||||
{
|
||||
private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) =>
|
||||
new(options, inner, NullLogger<OtOpcUaLdapAuthService>.Instance);
|
||||
|
||||
/// <summary>DevStubMode on → stub FleetAdmin success WITHOUT hitting the library.</summary>
|
||||
[Fact]
|
||||
public async Task DevStubMode_grants_FleetAdmin_without_calling_the_library()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
var sut = Build(new LdapOptions { Enabled = true, DevStubMode = true }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("anyone", "anything", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Username.ShouldBe("anyone");
|
||||
result.Groups.ShouldBe(new[] { "dev" });
|
||||
result.Roles.ShouldBe(new[] { "FleetAdmin" });
|
||||
inner.Called.ShouldBeFalse("DevStubMode must never reach the real directory client");
|
||||
}
|
||||
|
||||
/// <summary>Enabled=false → denial, no library call (master switch wins over DevStubMode).</summary>
|
||||
[Fact]
|
||||
public async Task Disabled_denies_without_calling_the_library_even_with_devstub()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(new LdapOptions { Enabled = false, DevStubMode = true }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("user", "pw", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("LDAP authentication is disabled.");
|
||||
inner.Called.ShouldBeFalse("a disabled provider must never touch the network");
|
||||
}
|
||||
|
||||
/// <summary>Real path: a library success surfaces its Groups; Roles are left empty for the
|
||||
/// downstream mapper (the library returns groups, not roles).</summary>
|
||||
[Fact]
|
||||
public async Task Real_path_success_surfaces_groups_and_leaves_roles_for_the_mapper()
|
||||
{
|
||||
var inner = new RecordingLibService(
|
||||
LibLdapAuthResult.Success("alice", "Alice User", new[] { "ReadOnly", "Engineers" }));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
inner.Called.ShouldBeTrue();
|
||||
result.Success.ShouldBeTrue();
|
||||
result.Username.ShouldBe("alice");
|
||||
result.DisplayName.ShouldBe("Alice User");
|
||||
result.Groups.ShouldBe(new[] { "ReadOnly", "Engineers" });
|
||||
result.Roles.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Real path: a library failure folds into a fail-closed error string.</summary>
|
||||
[Fact]
|
||||
public async Task Real_path_failure_folds_into_error()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Fail(LdapAuthFailure.BadCredentials));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.Ldaps },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "wrong", CancellationToken.None);
|
||||
|
||||
inner.Called.ShouldBeTrue();
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldBe("Invalid username or password");
|
||||
}
|
||||
|
||||
/// <summary>Insecure transport without AllowInsecure fails closed at the auth boundary WITHOUT
|
||||
/// reaching the library — preserving the bespoke service's login-time guard after UseTls→Transport.</summary>
|
||||
[Fact]
|
||||
public async Task Insecure_transport_without_AllowInsecure_fails_closed_without_calling_library()
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(
|
||||
new LdapOptions { Enabled = true, DevStubMode = false, Transport = LdapTransport.None, AllowInsecure = false },
|
||||
inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync("alice", "secret", CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
result.Error.ShouldNotBeNull();
|
||||
result.Error!.ShouldContain("Insecure LDAP is disabled");
|
||||
inner.Called.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Empty username/password are rejected up front without a library call.</summary>
|
||||
[Theory]
|
||||
[InlineData("", "pw")]
|
||||
[InlineData("user", "")]
|
||||
public async Task Empty_credentials_are_rejected_without_calling_library(string user, string pw)
|
||||
{
|
||||
var inner = new RecordingLibService(LibLdapAuthResult.Success("x", "x", new[] { "g" }));
|
||||
var sut = Build(new LdapOptions { Enabled = true, Transport = LdapTransport.Ldaps }, inner);
|
||||
|
||||
var result = await sut.AuthenticateAsync(user, pw, CancellationToken.None);
|
||||
|
||||
result.Success.ShouldBeFalse();
|
||||
inner.Called.ShouldBeFalse();
|
||||
}
|
||||
|
||||
/// <summary>Records whether the library service was invoked and returns a canned result.</summary>
|
||||
private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService
|
||||
{
|
||||
public bool Called { get; private set; }
|
||||
|
||||
public Task<LibLdapAuthResult> AuthenticateAsync(string username, string password, CancellationToken ct)
|
||||
{
|
||||
Called = true;
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user