using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; using ZB.MOM.WW.OtOpcUa.Security.Ldap; 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 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. /// /// /// Resilience (arch-review 03/S2). The OPC UA SDK raises the impersonation callback that /// reaches here synchronously, inside a SessionManager-wide event lock (verified /// against OPCFoundation.NetStandard.Opc.Ua.Server 1.5.378 — /// SessionManager.ActivateSessionAsync holds m_eventLock while invoking the /// void impersonation delegate). A slow / hung LDAP bind inside that callback therefore does /// not merely stall one SDK thread — it serializes every session activation server-wide /// (Anonymous and X509 included). The callback contract is irreducibly synchronous (there is no /// async impersonation callback), so the fix lives entirely at this boundary: a hard whole-flow /// wall-clock bound () and a bounded in-flight concurrency cap /// (), both fail-closed. All new paths deny. /// /// public sealed class LdapOpcUaUserAuthenticator : IOpcUaUserAuthenticator { private readonly ILdapAuthService _ldap; private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger _logger; private readonly LdapOptions _options; /// Bounds in-flight authentication flows. A boundary timeout abandons its core task, which /// keeps holding its slot until it actually completes — so an outage cannot accumulate unbounded /// orphan binds. private readonly SemaphoreSlim _gate; /// Constructs the authenticator from its dependencies and the bound LDAP options. /// The app LDAP auth service (shared with the Admin UI login flow). /// Opens a per-call DI scope to resolve the scoped role mapper. /// The bound (timeout + concurrency knobs). /// The logger. public LdapOpcUaUserAuthenticator( ILdapAuthService ldap, IServiceScopeFactory scopeFactory, IOptions options, ILogger logger) { _ldap = ldap; _scopeFactory = scopeFactory; _options = options.Value; _logger = logger; _gate = new SemaphoreSlim(Math.Max(1, _options.MaxConcurrentAuthentications)); } /// public async Task AuthenticateUserNameAsync(string username, string password, CancellationToken ct) { var authTimeout = TimeSpan.FromMilliseconds(Math.Max(1, _options.AuthTimeoutMs)); // Bounded in-flight concurrency: acquire a slot within our own wall-clock budget. All slots // busy is LOCAL backpressure, not directory evidence — deny "busy" (fail-closed). if (!await _gate.WaitAsync(authTimeout, ct).ConfigureAwait(false)) { _logger.LogWarning( "LDAP authentication backend busy (concurrency cap {Cap}) — denying OPC UA user {User}", _options.MaxConcurrentAuthentications, username); return OpcUaUserAuthResult.Deny("Authentication backend busy"); } // Release the slot when the CORE task completes — deliberately NOT in a finally here. A boundary // timeout below abandons the core task (it runs to completion in the background; the underlying // library is documented never-throw, so there are no unobserved exceptions), and it must keep // counting against the cap until it finishes. The point of the boundary is to release the SDK // thread (and m_eventLock), not to cancel the bind. var core = AuthenticateCoreAsync(username, password, ct); _ = core.ContinueWith( static (_, state) => ((SemaphoreSlim)state!).Release(), _gate, CancellationToken.None, TaskContinuationOptions.None, TaskScheduler.Default); try { return await core.WaitAsync(authTimeout, ct).ConfigureAwait(false); } catch (TimeoutException) { _logger.LogWarning( "LDAP authentication timed out after {Timeout} for OPC UA user {User} — denying (fail-closed)", authTimeout, username); return OpcUaUserAuthResult.Deny("Authentication timed out"); } } /// /// The unbounded core authenticate flow (bind + role-map) that the outer /// wraps in a wall-clock bound + concurrency cap. Fail-closed: /// any backend throw becomes a denial rather than escaping into the SDK. /// /// The username to authenticate. /// The cleartext password. /// Cancellation token. private async Task AuthenticateCoreAsync(string username, string password, CancellationToken ct) { try { var result = await _ldap.AuthenticateAsync(username, password, ct).ConfigureAwait(false); if (!result.Success) { return OpcUaUserAuthResult.Deny(result.Error ?? "Invalid credentials"); } 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) { _logger.LogWarning(ex, "LDAP authentication threw for OPC UA user {User}", username); 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; } } }