From 257caa7bd14480a3dc75cba62b1c8c7b7ab2f988 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 2 Jun 2026 00:55:10 -0400 Subject: [PATCH] feat(auth): cut OtOpcUa over to ZB.MOM.WW.Auth.Ldap; preserve DevStubMode; route roles via IGroupRoleMapper (Task 1.2/1.4) --- .../Components/Pages/RoleGrants.razor | 4 +- .../Configuration/LdapOptionsValidator.cs | 5 +- .../OpcUa/LdapOpcUaUserAuthenticator.cs | 52 ++++- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 13 +- .../Endpoints/AuthEndpoints.cs | 34 +++- .../Ldap/LdapAuthService.cs | 183 ------------------ .../Ldap/LdapOptions.cs | 50 ++++- .../Ldap/OtOpcUaLdapAuthService.cs | 134 +++++++++++++ .../ServiceCollectionExtensions.cs | 10 +- .../ZB.MOM.WW.OtOpcUa.Security.csproj | 2 +- .../LdapOpcUaUserAuthenticatorTests.cs | 83 ++++++-- .../TwoNodeClusterHarness.cs | 18 +- .../LdapHelperTests.cs | 46 ----- .../OtOpcUaLdapAuthServiceTests.cs | 135 +++++++++++++ 14 files changed, 495 insertions(+), 274 deletions(-) delete mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs delete mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor index 4ba3abfa..af926189 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/RoleGrants.razor @@ -20,9 +20,9 @@
LDAP binding
Enabled@(_options.Enabled ? "yes" : "no")
Server@_options.Server:@_options.Port
-
UseTls@_options.UseTls
+
Transport@_options.Transport
SearchBase@_options.SearchBase
- @if (!_options.UseTls && _options.AllowInsecureLdap) + @if (_options.Transport == ZB.MOM.WW.Auth.Abstractions.Ldap.LdapTransport.None && _options.AllowInsecure) {
WarningPlaintext credentials over LDAP — dev mode only
} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs index 0d3f0377..e95377c8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/LdapOptionsValidator.cs @@ -10,7 +10,10 @@ namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; /// TCP port; when disabled — or when DevStubMode bypasses the real bind — all checks are /// skipped. ServiceAccountDn/Password are /// intentionally not required — an empty pair selects the direct-bind path (see -/// ). Failure messages use "Ldap:" as a +/// ). The plaintext-transport-without-AllowInsecure +/// guard is enforced at the auth boundary () 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 "Ldap:" as a /// human-readable field prefix — not the literal bound section path, which is /// Security:Ldap (see ). /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs index 45410dbe..62858193 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/OpcUa/LdapOpcUaUserAuthenticator.cs @@ -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 adapter that bridges OPC UA UserName /// tokens to the same 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 OperationContext.UserIdentity downstream. +/// session identities. Roles are resolved through the shared +/// 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. /// +/// +/// This authenticator is registered as a singleton, but +/// (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. +/// public sealed class LdapOpcUaUserAuthenticator( ILdapAuthService ldap, + IServiceScopeFactory scopeFactory, ILogger logger) : IOpcUaUserAuthenticator { - /// Authenticates an OPC UA UserName token via LDAP. + /// Authenticates an OPC UA UserName token via LDAP, resolving roles through the mapper. /// The username to authenticate. /// The password to authenticate. /// Cancellation token. @@ -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"); } } + + /// + /// Resolves the user's roles from their LDAP groups via the scoped + /// , 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. + /// + /// The LDAP groups returned by the directory. + /// Pre-resolved roles (empty on the real path; FleetAdmin under DevStub). + /// The login name, for diagnostics. + /// Cancellation token. + private async Task> ResolveRolesAsync( + IReadOnlyList groups, IReadOnlyList preResolved, string username, CancellationToken ct) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var mapper = scope.ServiceProvider.GetRequiredService>(); + var mapping = await mapper.MapAsync(groups, ct).ConfigureAwait(false); + + var roles = new HashSet(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; + } + } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 82dcf61b..740e0c88 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -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(sp => sp.GetRequiredService()); builder.Services.AddValidatedOptions(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(); + // 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 + // 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(); + builder.Services.TryAddScoped, OtOpcUaGroupRoleMapper>(); builder.Services.AddSingleton(); // Bind + validate the OPC UA host options the same way (fail-fast at start via ValidateOnStart) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs index 4b53b524..43de033e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Endpoints/AuthEndpoints.cs @@ -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 LoginAsync( HttpContext http, ILdapAuthService ldap, - ILdapGroupRoleMappingService roleMappings, + IGroupRoleMapper 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 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 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()? .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 @@ -119,6 +126,21 @@ public static class AuthEndpoints return Results.Redirect(string.IsNullOrWhiteSpace(returnUrl) ? "/" : returnUrl); } + /// + /// Case-insensitive set-union of two role lists, preserving the de-duplication semantics the + /// legacy RoleMapper.Merge applied. Used to fold any pre-resolved roles (the DevStub + /// FleetAdmin grant) into the mapper-resolved set. + /// + /// The first role set (pre-resolved baseline). + /// The second role set (mapper output). + private static IReadOnlyList Union(IReadOnlyList first, IReadOnlyList second) + { + var roles = new HashSet(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(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs deleted file mode 100644 index bf3e4ea7..00000000 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapAuthService.cs +++ /dev/null @@ -1,183 +0,0 @@ -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Novell.Directory.Ldap; - -namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; - -/// -/// LDAP bind-and-search authentication mirrored from ScadaLink's LdapAuthService -/// (CLAUDE.md memory: scadalink_reference.md) — 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 ). -/// -public sealed class LdapAuthService(IOptions options, ILogger logger) - : ILdapAuthService -{ - private readonly LdapOptions _options = options.Value; - - /// Authenticates a user via LDAP bind and retrieves their group memberships and roles. - /// The username to authenticate. - /// The password to validate against the LDAP directory. - /// A cancellation token to observe while waiting for the operation to complete. - public async Task 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(); - - 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 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}"; - } - - /// Escapes special characters in an LDAP filter string according to RFC 4515. - /// The unescaped string to escape. - /// The escaped LDAP filter string. - internal static string EscapeLdapFilter(string input) => - input.Replace("\\", "\\5c") - .Replace("*", "\\2a") - .Replace("(", "\\28") - .Replace(")", "\\29") - .Replace("\0", "\\00"); - - /// - /// Pulls the first ou=Value segment from a DN. GLAuth encodes a user's primary - /// group as an ou= RDN immediately above the user's cn=, so this recovers - /// the group name when is absent from the entry. - /// - /// The distinguished name to extract the OU from. - /// The extracted OU value, or null if no OU segment is found. - 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; - } - - /// Extracts the value portion of the first RDN (relative distinguished name) from a DN. - /// The distinguished name to extract from. - /// The value of the first RDN. - 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..]; - } -} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs index 102671c0..96238de5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/LdapOptions.cs @@ -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; /// @@ -5,6 +8,14 @@ namespace ZB.MOM.WW.OtOpcUa.Security.Ldap; /// Security:Ldap section. Defaults point at the local GLAuth dev instance (see /// C:\publish\glauth\auth.md). /// +/// +/// Carries both the wire fields the shared ZB.MOM.WW.Auth.Ldap directory client needs +/// (///…) AND the app-only concerns +/// the shared library has no notion of ( master switch, +/// dev bypass, appsettings role baseline). +/// The app wrapper (OtOpcUaLdapAuthService) projects this onto the library's +/// at construction; see . +/// public sealed class LdapOptions { public const string SectionName = "Security:Ldap"; @@ -18,14 +29,21 @@ public sealed class LdapOptions /// Gets or sets the LDAP server port. public int Port { get; set; } = 3893; - /// Gets or sets a value indicating whether to use TLS for LDAP connection. - public bool UseTls { get; set; } + /// + /// Transport security for the LDAP connection — (implicit + /// TLS), (upgrade), or + /// (plaintext, dev/test only — requires ). Replaces the former + /// UseTls bool (Task 1.4): true, + /// false. + /// + public LdapTransport Transport { get; set; } = LdapTransport.None; - /// Dev-only escape hatch — must be false in production. - public bool AllowInsecureLdap { get; set; } + /// Dev-only escape hatch — must be false in production. Maps to the shared + /// library's (renamed from AllowInsecureLdap). + public bool AllowInsecure { get; set; } /// - /// Dev-only stub: when true, bypasses the real LDAP + /// Dev-only stub: when true, 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 false in production. /// @@ -62,4 +80,26 @@ public sealed class LdapOptions /// "ReadOnly":"ConfigViewer","ReadWrite":"ConfigEditor","AlarmAck":"FleetAdmin" /// public Dictionary GroupToRole { get; set; } = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Projects the wire fields onto the shared ZB.MOM.WW.Auth.Ldap + /// the directory client consumes. App-only concerns + /// (, ) have no library counterpart and are + /// handled by the app wrapper around the library service; is carried + /// through so the library's own feature gate stays consistent with the app master switch. + /// + 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, + }; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs new file mode 100644 index 00000000..9bfcb22d --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/Ldap/OtOpcUaLdapAuthService.cs @@ -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; + +/// +/// OtOpcUa's application — a thin wrapper around the shared +/// ZB.MOM.WW.Auth.Ldap directory client that adds the two app-only concerns the shared +/// library deliberately does not model: +/// +/// the master switch (disabled ⇒ deny, no bind); and +/// — 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. +/// +/// On the real path it delegates to the library and adapts the +/// library result (which returns groups, never roles) back onto the app's +/// shape. Role resolution itself now lives downstream in +/// (the IGroupRoleMapper<string> seam), which +/// both the login endpoint and the OPC UA data-plane authenticator call with the returned +/// . The only path that pre-populates +/// 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. +/// +/// +/// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The +/// DevStub result mirrors the legacy bespoke service exactly (group "dev", role +/// "FleetAdmin") so behaviour is preserved bit-for-bit on dev nodes. +/// +public sealed class OtOpcUaLdapAuthService : ILdapAuthService +{ + private readonly LdapOptions _options; + private readonly LibILdapAuthService _inner; + private readonly ILogger _logger; + + /// + /// Production constructor: builds the shared-library directory client from the wire fields + /// of the bound app . + /// + /// The app LDAP options (wire fields + app-only concerns). + /// The logger. + public OtOpcUaLdapAuthService(IOptions options, ILogger logger) + : this(options.Value, new LibLdapAuthService(options.Value.ToLibraryOptions()), logger) + { + } + + /// + /// Seam constructor: accepts an injected library so the + /// Enabled/DevStub/delegation logic can be unit-tested without a live directory. + /// + /// The app LDAP options. + /// The shared-library directory client to delegate the real path to. + /// The logger. + internal OtOpcUaLdapAuthService(LdapOptions options, LibILdapAuthService inner, ILogger logger) + { + _options = options; + _inner = inner; + _logger = logger; + } + + /// + public async Task 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); + } + + /// + /// Maps the shared-library onto the app's + /// . The library returns groups (never roles) on success, so + /// is left empty on the delegated path — role resolution + /// happens downstream in the mapper. Library codes are folded + /// into the user-facing error strings the app already surfaces, keeping fail-closed semantics. + /// + /// The library authentication result. + /// The login name, used to populate the app result's Username field. + 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)); + } + + /// Folds a structured library failure code into the app's user-facing error text. + /// The library failure code (null defensively treated as a generic error). + 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", + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs index fbf412ff..3c809188 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs @@ -36,11 +36,13 @@ public static class ServiceCollectionExtensions services.AddOptions().Bind(configuration.GetSection(LdapOptions.SectionName)); services.AddSingleton(); - // 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(); + 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 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 b33345ba..3dcc405a 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 @@ -12,8 +12,8 @@ - + diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs index 9adc49b4..0287f9dc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/LdapOpcUaUserAuthenticatorTests.cs @@ -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; /// -/// F13c — verifies faithfully translates -/// outcomes into OpcUaUserAuthResult and turns LDAP -/// backend exceptions into a denial rather than letting them escape into the SDK. +/// Verifies translates app +/// outcomes into OpcUaUserAuthResult, resolves roles from the directory's groups +/// through the shared 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. /// public sealed class LdapOpcUaUserAuthenticatorTests { - /// Verifies that successful LDAP authentication returns Allow result with user roles. + /// 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. [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.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(), null)); + var mapper = new FakeMapper(g => g.Select(x => x == "configeditor" ? "ConfigEditor" : x).ToArray()); + var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("alice", "secret", CancellationToken.None); @@ -27,12 +35,45 @@ public sealed class LdapOpcUaUserAuthenticatorTests result.Roles.ShouldBe(new[] { "ConfigEditor" }); } + /// 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. + [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()); + var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); + + var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); + + result.Success.ShouldBeTrue(); + result.Roles.ShouldBe(new[] { "FleetAdmin" }); + } + + /// 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. + [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.Instance); + + var result = await sut.AuthenticateUserNameAsync("dev", "anything", CancellationToken.None); + + result.Success.ShouldBeTrue(); + result.Roles.ShouldBe(new[] { "FleetAdmin" }); + } + /// Verifies that LDAP authentication failure returns Deny result with error text. [Fact] public async Task Authenticate_LDAP_failure_returns_Deny_with_error_text() { var ldap = new FakeLdap(new LdapAuthResult(false, null, "mallory", Array.Empty(), Array.Empty(), "Invalid username or password")); - var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance); + var mapper = new FakeMapper(g => g.ToArray()); + var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.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.Instance); + var mapper = new FakeMapper(g => g.ToArray()); + var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.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(), new[] { "ReadOnly" }, null)); - var sut = new LdapOpcUaUserAuthenticator(ldap, NullLogger.Instance); + var ldap = new FakeLdap(new LdapAuthResult(true, null, "alice", new[] { "ReadOnly" }, Array.Empty(), null)); + var mapper = new FakeMapper(g => g.ToArray()); + var sut = new LdapOpcUaUserAuthenticator(ldap, ScopeFactoryWith(mapper), NullLogger.Instance); var result = await sut.AuthenticateUserNameAsync("alice", "x", CancellationToken.None); @@ -67,6 +110,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests result.DisplayName.ShouldBe("alice"); } + /// Builds an IServiceScopeFactory whose scopes resolve the supplied mapper. + private static IServiceScopeFactory ScopeFactoryWith(IGroupRoleMapper mapper) + { + var services = new ServiceCollection(); + services.AddScoped(_ => mapper); + return services.BuildServiceProvider().GetRequiredService(); + } + /// Test fake implementation of LDAP authentication service. private sealed class FakeLdap : ILdapAuthService { @@ -87,4 +138,14 @@ public sealed class LdapOpcUaUserAuthenticatorTests public Task AuthenticateAsync(string username, string password, CancellationToken ct = default) => Task.FromResult(_handler(username)); } + + /// Test fake group→role mapper driven by a delegate over the supplied groups. + private sealed class FakeMapper(Func, IReadOnlyList> map) : IGroupRoleMapper + { + /// Maps groups to roles via the configured delegate; Scope is always null. + /// The LDAP groups to map. + /// The cancellation token. + public Task> MapAsync(IReadOnlyList groups, CancellationToken ct) + => Task.FromResult(new GroupRoleMapping(map(groups), Scope: null)); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs index 7a43abfd..8485b498 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/TwoNodeClusterHarness.cs @@ -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); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs deleted file mode 100644 index c215c044..00000000 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/LdapHelperTests.cs +++ /dev/null @@ -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 -{ - /// Verifies that LDAP filter special characters are properly escaped. - /// The input string. - /// The expected escaped output. - [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); - } - - /// Verifies that the first organizational unit segment is correctly extracted from a DN. - /// The distinguished name. - /// The expected organizational unit value. - [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); - } - - /// Verifies that the first RDN value is correctly extracted from various DN formats. - /// The distinguished name. - /// The expected RDN value. - [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); - } -} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs new file mode 100644 index 00000000..d6c30137 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Security.Tests/OtOpcUaLdapAuthServiceTests.cs @@ -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; + +/// +/// Task 1.2 — proves (the app's ILdapAuthService wrapper over +/// the shared ZB.MOM.WW.Auth.Ldap service) preserves the two app-only concerns the library +/// does not model: the Enabled master switch and the DevStubMode 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. +/// +public sealed class OtOpcUaLdapAuthServiceTests +{ + private static OtOpcUaLdapAuthService Build(LdapOptions options, RecordingLibService inner) => + new(options, inner, NullLogger.Instance); + + /// DevStubMode on → stub FleetAdmin success WITHOUT hitting the library. + [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"); + } + + /// Enabled=false → denial, no library call (master switch wins over DevStubMode). + [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"); + } + + /// Real path: a library success surfaces its Groups; Roles are left empty for the + /// downstream mapper (the library returns groups, not roles). + [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(); + } + + /// Real path: a library failure folds into a fail-closed error string. + [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"); + } + + /// 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. + [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(); + } + + /// Empty username/password are rejected up front without a library call. + [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(); + } + + /// Records whether the library service was invoked and returns a canned result. + private sealed class RecordingLibService(LibLdapAuthResult result) : LibILdapAuthService + { + public bool Called { get; private set; } + + public Task AuthenticateAsync(string username, string password, CancellationToken ct) + { + Called = true; + return Task.FromResult(result); + } + } +}