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 an Administrator /// 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 Administrator grant survives the move to the mapper. /// /// /// Fail-closed: the library never throws, and this wrapper adds no new throwing paths. The /// DevStub result grants the canonical "Administrator" control-plane role (group /// "dev") so the dev session can navigate the full Admin UI (renamed from the prior /// "FleetAdmin" to the canonical "Administrator"). /// 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 Administrator WITHOUT a real bind. // Pre-populated Roles are unioned with the mapper output by both consumers, so the grant // survives the move to IGroupRoleMapper. (The role string was canonicalized from the // prior "FleetAdmin" to "Administrator".) _logger.LogWarning( "OtOpcUaLdapAuthService: DevStubMode bypass — accepting {User} without a real LDAP bind", username); return new(true, username, username, ["dev"], ["Administrator"], 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. 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); // ServiceAccountBindFailed is the library's directory-unreachable / service-account-misconfigured // bucket (its enum has no dedicated DirectoryUnavailable) — flag it as system-side so the OPC UA // authenticator's outage circuit can distinguish an outage from a user-credential deny (03/S2). return new(false, null, username, [], [], FailureToError(result.Failure)) { IsSystemFailure = result.Failure is LdapAuthFailure.ServiceAccountBindFailed, }; } /// 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", }; }